Skip to content
Back to skills

Paypal Api

ASecurity

Implements PayPal API integration (Orders, Payments, Subscriptions, Payouts,

  • 4 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added June 12, 2026
developmentpythonrustgogitapidatabasefrontendbackendsecurity

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned June 12, 2026

npx -y skills add paulpas/agent-skill-router --skill paypal-api --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Paypal Api?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Paypal Api
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-paypal-api/badge)](https://www.skillsdirectory.com/skills/paulpas-paypal-api)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---




name: paypal-api
description: Implements PayPal API integration (Orders, Payments, Subscriptions, Payouts,
  Disputes) using paypal-checkout-serversdk or paypalrestsdk with webhook verification,
  payment capture, and subscription lifecycle management.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: coding
  triggers: paypal, paypal orders api, paypal subscriptions, paypal payouts, paypal
    webhook verification, capture payment, how do i integrate paypal payments, paypal
    checkout
  archetypes:
  - tactical
  - generation
  anti_triggers:
  - brainstorming
  - vague ideation
  - code golf
  - over-engineering
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
  role: implementation
  scope: implementation
  output-format: code
  content-types:
  - code
  - guidance
  - do-dont
  - examples
  related-skills: coding-stripe-api, coding-braintree-api, coding-square-api




---




# PayPal API Integration

Implements production-grade PayPal API integration using the official PayPal Python SDKs. When loaded, this skill makes the model implement Orders v2 API for one-time payments, Subscriptions API for recurring billing, Payouts API for mass payments, and webhook handling with signature verification. All implementations follow PayPal security best practices: verify webhook signatures, capture authorized payments before fulfillment, handle payment status transitions, and never expose client secrets to frontend code.

## TL;DR Checklist

- [ ] Use Orders v2 API (NOT v1 deprecated)
- [ ] Create order with `intent: "CAPTURE"` or `intent: "AUTHORIZE"`
- [ ] Capture authorized payments BEFORE fulfilling orders
- [ ] Verify webhooks using `verify_webhook_signature`
- [ ] Use `PayPalHttpClient` from checkout SDK (not requests directly)
- [ ] Store `order_id` (`...`) and `capture_id` for tracking
- [ ] Handle `APPROVED` → `COMPLETED` status transitions
- [ ] NEVER hardcode client_id and client_secret in source

---

## When to Use

Use this skill when:

- Building one-time checkout flows with PayPal Smart Payment Buttons
- Implementing subscription billing with automatic recurring payments
- Sending mass payouts via Payouts API
- Handling disputes and chargebacks via Disputes API
- Processing refunds on captured payments
- Verifying payment webhooks for order fulfillment
- Integrating PayPal with Venmo and other PayPal-owned methods

---

## When NOT to Use

- For Stripe-specific flows — use `coding-stripe-api` instead
- When you need a unified payment orchestrator across many gateways
- For Square retail/in-person payments — use `coding-square-api`
- For Adyen global enterprise payments — use `coding-adyen-api`

---

## Core Workflow

1. **Initialize PayPal Client** — Create a `PayPalHttpClient` configured from `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET` environment variables. Set `sandbox` mode for development, `live` for production. **Checkpoint:** Get an access token via `/v1/oauth2/token` to verify credentials work.

2. **Create Order** — Use Orders v2 API with `intent: "CAPTURE"` for immediate capture or `intent: "AUTHORIZE"` for delayed capture. Include purchase_units with amount, reference_id, and custom_id for your internal order tracking. **Checkpoint:** Return the `order_id` and `approve` link to frontend for PayPal login/approval flow.

3. **Handle Approval Callback** — After customer approves in PayPal, frontend returns `order_id` to your backend. Call `orders.capture()` with that ID. Verify the response shows `status: "COMPLETED"` and `capture.status: "COMPLETED"`. **Checkpoint:** The `capture_id` is what you use for refunds later — store it.

4. **Verify Webhook Signature** — Create endpoint that receives PayPal webhooks. Extract `PAYPAL-TRANSMISSION-ID`, `PAYPAL-TRANSMISSION-TIME`, `PAYPAL-TRANSMISSION-SIG`, `PAYPAL-CERT-URL` headers. Call verify endpoint or SDK method with webhook_id and these headers. **Checkpoint:** Only process webhook after verification succeeds.

5. **Fulfill Order After Capture** — Update your database ONLY when: (a) `orders.capture()` returns `COMPLETED`, OR (b) verified webhook event `PAYMENT.CAPTURE.COMPLETED` arrives. **Checkpoint:** Never trust frontend success alone — ALWAYS verify at backend.

---

## Implementation Patterns

### Pattern 1: PayPal Client Initialization (BAD vs GOOD)

```python
"""PayPal SDK initialization patterns.

Two SDK options:
1. paypalcheckoutsdk (recommended for Orders v2)
2. paypalrestsdk (older, used for some v1 APIs)

Always use environment variables for credentials.
"""

from __future__ import annotations

import os
import logging
from typing import Any, Optional

logger = logging.getLogger(__name__)


# ===================================================================
# ❌ BAD — hardcoded credentials, manual requests
# ===================================================================

import json
import requests
import base64

PAYPAL_CLIENT_ID = "Ac9...real_key_here"  # ❌ HARDCODED!
PAYPAL_CLIENT_SECRET = "EFn...secret_here"  # ❌ HARDCODED!
PAYPAL_BASE = "https://api-m.sandbox.paypal.com"

def bad_get_access_token_bad() -> str:
    """❌ BAD: Hardcoded keys, manual HTTP, no error handling."""
    auth = base64.b64encode(f"{PAYPAL_CLIENT_ID}:{PAYPAL_CLIENT_SECRET}".encode()).decode()
    response = requests.post(
        f"{PAYPAL_BASE}/v1/oauth2/token",
        headers={"Authorization": f"Basic {auth}"},
        data={"grant_type": "client_credentials"},
    )
    return response.json()["access_token"]  # ❌ No error checking!


# ===================================================================
# ✅ GOOD — Environment-based, SDK pattern, typed error handling
# ===================================================================

try:
    # Modern: paypal-checkout-serversdk (for Orders v2)
    from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
    from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest, OrdersGetRequest
    from paypalcheckoutsdk.payments import CapturesRefundRequest
    CHECKOUT_SDK_AVAILABLE = True
except ImportError:
    CHECKOUT_SDK_AVAILABLE = False

try:
    # Legacy: paypalrestsdk (for Subscriptions, Billing Plans)
    import paypalrestsdk
    REST_SDK_AVAILABLE = True
except ImportError:
    REST_SDK_AVAILABLE = False


def get_paypal_environment() -> Any:
    """Get PayPal environment from configuration.

    Reads:
        PAYPAL_CLIENT_ID
        PAYPAL_CLIENT_SECRET
        PAYPAL_MODE: "sandbox" or "live"

    Returns:
        SandboxEnvironment or LiveEnvironment instance.
    """
    client_id = os.environ.get("PAYPAL_CLIENT_ID")
    client_secret = os.environ.get("PAYPAL_CLIENT_SECRET")
    mode = os.environ.get("PAYPAL_MODE", "sandbox").lower()

    if not client_id or not client_secret:
        raise ValueError("PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET required")

    if mode == "live":
        return LiveEnvironment(client_id=client_id, client_secret=client_secret)
    else:
        return SandboxEnvironment(client_id=client_id, client_secret=client_secret)


def get_paypal_client() -> PayPalHttpClient:
    """Get configured PayPalHttpClient instance.

    Returns:
        Configured HTTP client for PayPal API calls.
    """
    if not CHECKOUT_SDK_AVAILABLE:
        raise RuntimeError("paypalcheckoutsdk not installed. pip install paypal-checkout-serversdk")
    
    return PayPalHttpClient(get_paypal_environment())


# Configure legacy paypalrestsdk if available
def configure_paypal_rest_sdk() -> None:
    """Configure the older paypalrestsdk (for Subscriptions API)."""
    if not REST_SDK_AVAILABLE:
        logger.warning("paypalrestsdk not available")
        return
    
    mode = os.environ.get("PAYPAL_MODE", "sandbox")
    paypalrestsdk.configure({
        "mode": mode,
        "client_id": os.environ.get("PAYPAL_CLIENT_ID"),
        "client_secret": os.environ.get("PAYPAL_CLIENT_SECRET"),
    })
```

### Pattern 2: Create and Capture Order (Orders v2 API)

```python
"""Orders v2 API is the modern way to process PayPal payments.

Flow:
1. Backend: Create order → returns order.id, approve link
2. Frontend: Redirect to PayPal or use Smart Buttons
3. User: Logs into PayPal, approves payment
4. Frontend: Returns to your site with order_id
5. Backend: CAPTURE the order → COMPLETED
6. Backend: Fulfill order

CRITICAL: Step 5 (CAPTURE) is REQUIRED before fulfillment!
"""

from __future__ import annotations

import uuid
from typing import Any, Optional
from decimal import Decimal


def create_order(
    amount: Decimal,
    currency: str = "USD",
    reference_id: str | None = None,
    custom_id: str | None = None,
    description: str = "",
    return_url: str = "",
    cancel_url: str = "",
) -> tuple[str, str]:
    """Create a PayPal order for one-time payment.

    Uses Orders v2 API with intent: CAPTURE.

    Args:
        amount: Payment amount (will be converted to 2 decimal places).
        currency: ISO 4217 currency code (USD, EUR, GBP, etc.).
        reference_id: Your internal reference (e.g., order number).
        custom_id: Your internal tracking ID (appears in webhooks).
        description: Item description shown to customer.
        return_url: URL after successful approval (for redirect flow).
        cancel_url: URL if customer cancels.

    Returns:
        Tuple of (order_id, approve_url)
        order_id: Pass to capture() after approval
        approve_url: Redirect user here or use with Smart Buttons

    Example:
        order_id, approve_url = create_order(
            amount=Decimal("99.99"),
            currency="USD",
            custom_id="order_12345",
            return_url="https://example.com/payment-success",
            cancel_url="https://example.com/payment-cancelled",
        )
    """
    client = get_paypal_client()
    
    request = OrdersCreateRequest()
    request.prefer("return=representation")
    
    amount_str = f"{amount:.2f}"
    
    request.request_body({
        "intent": "CAPTURE",
        "purchase_units": [{
            "reference_id": reference_id or "default",
            "custom_id": custom_id,
            "description": description,
            "amount": {
                "currency_code": currency,
                "value": amount_str,
            }
        }],
        "application_context": {
            "return_url": return_url,
            "cancel_url": cancel_url,
            "shipping_preference": "NO_SHIPPING",  # or SET_PROVIDED_ADDRESS, GET_FROM_FILE
            "user_action": "PAY_NOW",
        }
    })
    
    response = client.execute(request)
    order = response.result
    
    order_id = order.id
    
    # Find the approve link
    approve_url = ""
    for link in order.links:
        if link.rel == "approve":
            approve_url = link.href
            break
    
    logger.info("Created PayPal order: %s", order_id)
    return order_id, approve_url


def get_order_details(order_id: str) -> Any:
    """Get order details to check its current status.

    Useful to verify status before capture, or to get more details.

    Status values:
    - CREATED: Order created, not approved yet
    - SAVED: Order saved but not yet approved
    - APPROVED: Customer approved — READY TO CAPTURE!
    - VOIDED: Order voided/cancelled
    - COMPLETED: Order captured and payment complete

    Args:
        order_id: The PayPal order ID (from create_order)

    Returns:
        Order object from SDK.
    """
    client = get_paypal_client()
    request = OrdersGetRequest(order_id)
    response = client.execute(request)
    return response.result


def capture_order(order_id: str, amount: Decimal | None = None) -> dict[str, Any]:
    """Capture an APPROVED PayPal order.

    THIS IS THE CRITICAL STEP!
    After customer approves in PayPal, you MUST call capture()
    to actually get the money. "Approved" doesn't mean "Paid".

    Args:
        order_id: The approved order ID.
        amount: Optional amount (for partial captures).

    Returns:
        Dict with capture details: capture_id, status, amount, etc.

    Raises:
        RuntimeError: If capture fails or status is not COMPLETED.
    """
    client = get_paypal_client()
    
    request = OrdersCaptureRequest(order_id)
    
    if amount:
        request.request_body({
            "amount": {
                "currency_code": "USD",
                "value": f"{amount:.2f}",
            }
        })
    
    try:
        response = client.execute(request)
    except Exception as e:
        logger.error("PayPal capture failed for order %s: %s", order_id, e)
        raise RuntimeError(f"PayPal capture failed: {e}") from e
    
    capture = response.result
    
    # Find the capture in purchase_units[0].payments.captures[0]
    capture_id = None
    capture_status = capture.status
    
    if hasattr(capture, "purchase_units") and capture.purchase_units:
        pu = capture.purchase_units[0]
        if hasattr(pu, "payments") and hasattr(pu.payments, "captures"):
            cap_list = pu.payments.captures
            if cap_list:
                capture_id = cap_list[0].id
                capture_status = cap_list[0].status
    
    if capture_status != "COMPLETED":
        logger.warning(
            "PayPal capture status is not COMPLETED: order=%s status=%s",
            order_id, capture_status
        )
        raise RuntimeError(f"PayPal capture incomplete: status={capture_status}")
    
    result = {
        "order_id": order_id,
        "capture_id": capture_id,
        "status": capture_status,
        "raw": capture,  # Full SDK object
    }
    
    logger.info("Captured PayPal order: %s capture=%s", order_id, capture_id)
    return result


def refund_capture(
    capture_id: str,
    amount: Decimal | None = None,
    currency: str = "USD",
    note: str = "",
) -> dict[str, Any]:
    """Refund a captured payment.

    Args:
        capture_id: The capture ID from capture_order()
        amount: Optional amount for partial refund; full refund if None.
        currency: Currency code.
        note: Reason for refund (shown to customer).

    Returns:
        Dict with refund_id and status.
    """
    client = get_paypal_client()
    
    request = CapturesRefundRequest(capture_id)
    
    if amount:
        request.request_body({
            "amount": {
                "currency_code": currency,
                "value": f"{amount:.2f}",
            },
            "note_to_payer": note,
        })
    
    response = client.execute(request)
    refund = response.result
    
    return {
        "refund_id": refund.id,
        "status": refund.status,
        "amount": getattr(refund, "amount", None),
    }
```

### Pattern 3: Webhook Verification (CRITICAL for PayPal)

```python
"""PayPal webhook verification.

PayPal webhooks are how you get notified of:
- PAYMENT.CAPTURE.COMPLETED — payment completed (most important!)
- PAYMENT.CAPTURE.DENIED — capture denied
- PAYMENT.CAPTURE.REFUNDED — refund issued
- BILLING.SUBSCRIPTION.CREATED — subscription started
- BILLING.SUBSCRIPTION.ACTIVATED — subscription activated
- BILLING.SUBSCRIPTION.CANCELLED — subscription cancelled

NEVER trust webhooks without VERIFYING the signature.
PayPal provides verify endpoint, but you can also use SDK.

To get your WEBHOOK_ID:
1. Go to PayPal Dashboard → My Apps & Credentials
2. Select your app → Sandbox or Live
3. Scroll to WEBHOOKS section
4. Add webhook → get webhook_id from the list

Each webhook endpoint has its own webhook_id.
"""

from __future__ import annotations

import os
import hmac
import hashlib
import base64
import logging
import requests
from typing import Any, Callable

logger = logging.getLogger(__name__)

# Get this from PayPal Dashboard → Webhooks list
WEBHOOK_ID = os.environ.get("PAYPAL_WEBHOOK_ID", "")


class PayPalWebhookVerifier:
    """Verifies PayPal webhook signatures.

    There are two approaches:
    1. Hit PayPal's /v1/notifications/verify-webhook-signature (reliable, slow)
    2. Verify locally using HMAC (faster, but needs cert URL validation)

    This implementation uses #1 for reliability.
    """
    
    def __init__(
        self,
        client_id: str | None = None,
        client_secret: str | None = None,
        mode: str = "sandbox",
        webhook_id: str | None = None,
    ):
        self.client_id = client_id or os.environ.get("PAYPAL_CLIENT_ID", "")
        self.client_secret = client_secret or os.environ.get("PAYPAL_CLIENT_SECRET", "")
        self.mode = mode.lower()
        self.webhook_id = webhook_id or WEBHOOK_ID
        
        if self.mode == "live":
            self.base_url = "https://api-m.paypal.com"
        else:
            self.base_url = "https://api-m.sandbox.paypal.com"
        
        self._access_token: str | None = None
        self._token_expiry: float = 0
    
    def _get_access_token(self) -> str:
        """Get OAuth2 access token for PayPal API."""
        import time
        
        if self._access_token and time.time() < self._token_expiry:
            return self._access_token
        
        auth = base64.b64encode(
            f"{self.client_id}:{self.client_secret}".encode()
        ).decode()
        
        response = requests.post(
            f"{self.base_url}/v1/oauth2/token",
            headers={
                "Authorization": f"Basic {auth}",
                "Content-Type": "application/x-www-form-urlencoded",
            },
            data={"grant_type": "client_credentials"},
            timeout=30,
        )
        
        if response.status_code != 200:
            raise RuntimeError(f"Failed to get PayPal token: {response.text}")
        
        data = response.json()
        self._access_token = data["access_token"]
        self._token_expiry = time.time() + data.get("expires_in", 3200)
        
        return self._access_token
    
    def verify_webhook(
        self,
        payload: str,  # Raw JSON string of request body
        transmission_id: str,
        transmission_time: str,
        transmission_sig: str,
        cert_url: str,
        webhook_id: str | None = None,
    ) -> bool:
        """Verify PayPal webhook signature.

        Args:
            payload: Raw request body as JSON string (NOT parsed dict)
            transmission_id: PAYPAL-TRANSMISSION-ID header
            transmission_time: PAYPAL-TRANSMISSION-TIME header
            transmission_sig: PAYPAL-TRANSMISSION-SIG header
            cert_url: PAYPAL-CERT-URL header
            webhook_id: Your webhook ID from PayPal Dashboard (required)

        Returns:
            True if verification succeeds.

        Raises:
            ValueError: If verification fails or returns FAILURE.
        """
        actual_webhook_id = webhook_id or self.webhook_id
        if not actual_webhook_id:
            raise ValueError("PayPal webhook_id required for verification")
        
        # Basic cert URL sanity check — must be paypal.com
        if not cert_url.startswith("https://") or ".paypal.com/" not in cert_url:
            logger.warning("Suspicious cert_url: %s", cert_url)
            raise ValueError(f"Invalid cert_url (not paypal.com)")
        
        access_token = self._get_access_token()
        
        verify_body = {
            "transmission_id": transmission_id,
            "transmission_time": transmission_time,
            "cert_url": cert_url,
            "auth_algo": "SHA256withRSA",
            "transmission_sig": transmission_sig,
            "webhook_id": actual_webhook_id,
            "webhook_event": payload,  # Raw JSON string
        }
        
        response = requests.post(
            f"{self.base_url}/v1/notifications/verify-webhook-signature",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
            },
            json=verify_body,
            timeout=30,
        )
        
        if response.status_code != 200:
            logger.warning("PayPal webhook verify call failed: %d %s", response.status_code, response.text)
            raise ValueError(f"Webhook verify request failed: {response.status_code}")
        
        result = response.json()
        verification_status = result.get("verification_status")
        
        if verification_status == "SUCCESS":
            logger.info("PayPal webhook verified successfully")
            return True
        else:
            logger.warning("PayPal webhook verification FAILED: %s", result)
            raise ValueError(f"Webhook verification returned: {verification_status}")


class PayPalWebhookRouter:
    """Routes verified webhook events to handlers."""
    
    def __init__(self) -> None:
        self._handlers: dict[str, Callable[[Any], None]] = {}
    
    def on(self, event_type: str) -> Callable[[Callable], Callable]:
        """Decorator: @router.on("PAYMENT.CAPTURE.COMPLETED")"""
        def decorator(handler: Callable[[Any], None]) -> Callable[[Any], None]:
            self._handlers[event_type] = handler
            return handler
        return decorator
    
    def dispatch(self, event_dict: dict[str, Any]) -> bool:
        """Dispatch a verified webhook event."""
        event_type = event_dict.get("event_type")
        handler = self._handlers.get(event_type)
        
        if handler:
            try:
                handler(event_dict)
                return True
            except Exception:
                logger.exception("Handler failed for event type %s", event_type)
                raise
        return False


# Initialize router
paypal_webhook_router = PayPalWebhookRouter()


@paypal_webhook_router.on("PAYMENT.CAPTURE.COMPLETED")
def on_capture_completed(event: dict[str, Any]) -> None:
    """Handle successful payment capture.

    This is the MOST RELIABLE way to know payment went through.

    Event structure:
        event_type: "PAYMENT.CAPTURE.COMPLETED"
        resource: {
            "id": "5...",  # capture_id
            "amount": {"value": "99.99", "currency_code": "USD"},
            "custom_id": "your_order_123",  # if passed in create_order
            "status": "COMPLETED"
        }
    """
    resource = event.get("resource", {})
    capture_id = resource.get("id")
    custom_id = resource.get("custom_id")  # This is YOUR order ID!
    amount = resource.get("amount", {})
    
    logger.info(
        "PayPal capture completed: capture=%s custom_id=%s amount=%s %s",
        capture_id, custom_id, amount.get("value"), amount.get("currency_code")
    )
    
    # ✅ This is where you:
    # 1. Mark order as PAID using custom_id reference
    # 2. Send receipt email
    # 3. Fulfill the order


@paypal_webhook_router.on("BILLING.SUBSCRIPTION.ACTIVATED")
def on_subscription_activated(event: dict[str, Any]) -> None:
    """Handle subscription activation."""
    resource = event.get("resource", {})
    subscription_id = resource.get("id")
    logger.info("PayPal subscription activated: %s", subscription_id)


@paypal_webhook_router.on("BILLING.SUBSCRIPTION.CANCELLED")
def on_subscription_cancelled(event: dict[str, Any]) -> None:
    """Handle subscription cancellation."""
    resource = event.get("resource", {})
    subscription_id = resource.get("id")
    logger.info("PayPal subscription cancelled: %s", subscription_id)
```

### Pattern 4: Subscriptions (Legacy Billing Plans)

```python
"""PayPal Subscriptions using Billing Plans and Agreements.

Note: PayPal has both:
1. Old Billing Plans (v1) — used with paypalrestsdk
2. New Subscriptions API (v2) — Check REST API docs

This uses the older paypalrestsdk which is common in codebases.
"""

from __future__ import annotations

from decimal import Decimal
from typing import Any


def create_billing_plan(
    name: str,
    description: str,
    amount: Decimal,
    currency: str = "USD",
    interval: str = "month",  # day, week, month, year
    interval_count: int = 1,
    setup_fee: Decimal | None = None,
) -> Any:
    """Create a recurring billing plan.

    Args:
        name: Plan name shown to customers.
        description: Plan description.
        amount: Recurring amount.
        currency: ISO currency code.
        interval: Billing cycle interval (day/week/month/year).
        interval_count: Number of intervals per cycle (1 = monthly).
        setup_fee: Optional one-time setup fee.

    Returns:
        Created billing plan object (after activation).
    """
    configure_paypal_rest_sdk()
    if not REST_SDK_AVAILABLE:
        raise RuntimeError("paypalrestsdk not installed")
    
    billing_definitions = [{
        "name": "Regular payment",
        "type": "REGULAR",
        "frequency": {
            "interval_unit": interval.upper(),
            "interval_count": interval_count,
        },
        "amount": {
            "value": f"{amount:.2f}",
            "currency": currency,
        },
        "cycles": 0,  # 0 = infinite until cancelled
    }]
    
    if setup_fee:
        # Add one-time setup charge
        billing_definitions.insert(0, {
            "name": "Setup fee",
            "type": "TRIAL",
            "frequency": {
                "interval_unit": "DAY",
                "interval_count": 1,
            },
            "amount": {
                "value": f"{setup_fee:.2f}",
                "currency": currency,
            },
            "cycles": 1,
        })
    
    plan = paypalrestsdk.BillingPlan({
        "name": name,
        "description": description,
        "type": "INFINITE",  # or FIXED
        "payment_definitions": billing_definitions,
        "merchant_preferences": {
            "setup_fee": {
                "value": "0",
                "currency": currency,
            },
            "return_url": "http://example.com/subscribe/success",
            "cancel_url": "http://example.com/subscribe/cancel",
            "auto_bill_amount": "YES",
            "initial_fail_amount_action": "CONTINUE",
            "max_fail_attempts": 3,
        }
    })
    
    if plan.create():
        # Must ACTIVATE the plan after creating
        if plan.activate():
            logger.info("Created and activated billing plan: %s", plan.id)
            return plan
        else:
            raise RuntimeError(f"Failed to activate plan: {plan.error}")
    else:
        raise RuntimeError(f"Failed to create plan: {plan.error}")


def create_subscription_agreement(
    plan_id: str,
    name: str,
    description: str,
    start_date: str,  # ISO format with timezone: "2025-01-01T00:00:00Z"
    custom_id: str | None = None,
) -> tuple[str, str]:
    """Create a billing agreement (subscription) for a customer.

    Args:
        plan_id: The billing plan ID.
        name: Agreement name.
        description: Description.
        start_date: When the subscription should start (future date recommended).
        custom_id: Your internal reference for this subscription.

    Returns:
        Tuple of (agreement_id, approve_url)
    """
    configure_paypal_rest_sdk()
    if not REST_SDK_AVAILABLE:
        raise RuntimeError("paypalrestsdk not installed")
    
    agreement = paypalrestsdk.BillingAgreement({
        "name": name,
        "description": description,
        "start_date": start_date,
        "plan": {"id": plan_id},
        "payer": {"payment_method": "paypal"},
        "override_merchant_preferences": {
            "return_url": "http://example.com/subscribe/confirmed",
            "cancel_url": "http://example.com/subscribe/cancelled",
        },
    })
    
    if agreement.create():
        approve_url = ""
        for link in agreement.links:
            if link.rel == "approval_url":
                approve_url = link.href
                break
        
        logger.info("Created subscription agreement: %s", agreement.id)
        return agreement.id, approve_url
    else:
        raise RuntimeError(f"Failed to create agreement: {agreement.error}")


def execute_subscription_agreement(token: str) -> Any:
    """Execute agreement after customer approval.

    After customer approves via the approve_url, PayPal redirects
    back with a token parameter in the URL. Use that token here.

    Args:
        token: The token parameter from the redirect URL.

    Returns:
        Executed agreement object.
    """
    configure_paypal_rest_sdk()
    if not REST_SDK_AVAILABLE:
        raise RuntimeError("paypalrestsdk not installed")
    
    agreement = paypalrestsdk.BillingAgreement.execute(token)
    logger.info("Executed subscription agreement: %s", agreement.id)
    return agreement
```

---

## Constraints

### MUST DO

- Use Orders v2 API (not deprecated v1)
- Always CAPTURE approved orders before fulfillment
- Verify webhook signatures using PayPal's verify endpoint before processing
- Read `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID` from environment
- Store both `order_id` and `capture_id` for tracking and refunds
- Handle idempotency when processing webhooks (same event can arrive multiple times)
- Use `intent: "CAPTURE"` for immediate payments or `"AUTHORIZE"` for delayed capture
- Pass `custom_id` with your internal order ID for webhook reconciliation

### MUST NOT DO

- NEVER hardcode PayPal credentials in source code
- NEVER trust `APPROVED` status without calling `capture()` first
- NEVER fulfill based solely on frontend redirect success
- NEVER parse webhooks without verifying signatures first
- NEVER use v1 Charges API (deprecated)
- NEVER expose `client_secret` to frontend code
- NEVER rely solely on polling; always set up webhooks
- NEVER process same webhook event twice — track processed event_ids

---

## Output Template

When implementing PayPal integrations, produce:

1. **Client Factory** — Environment-based PayPalHttpClient or REST SDK config
2. **Order Flow** — Create → Approve → Capture pattern with status tracking
3. **Webhook Handler** — Signature verification + router for key event types
4. **Error Handling** — Mapping from PayPal exceptions to your business exceptions
5. **Database Tracking** — Columns for paypal_order_id, capture_id, status, custom_id
6. **Refund Support** — Refund by capture_id pattern

---

## Related Skills

| Skill | Purpose |
|-------|---------|
| `coding-stripe-api` | Stripe for primary payment processor alternative |
| `coding-braintree-api` | Braintree for PayPal + Venmo marketplace payments |
| `coding-square-api` | Square for in-person retail payments |
| `coding-adyen-api` | Adyen for global enterprise payments |
| `coding-shopify-api` | Shopify for ecommerce platform integration |

---

## Live References

| Resource | URL |
|----------|-----|
| PayPal Checkout Python SDK | https://github.com/paypal/Checkout-Python-SDK |
| PayPal REST SDK (legacy) | https://github.com/paypal/rest-api-sdk-python |
| Orders v2 API Reference | https://developer.paypal.com/docs/api/orders/v2/ |
| Subscriptions API | https://developer.paypal.com/docs/api/subscriptions/v1/ |
| Webhook Verification | https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature |
| Payouts API | https://developer.paypal.com/docs/api/payments.payouts-batch/v1/ |
| PayPal Developer Docs | https://developer.paypal.com/docs/api/overview/ |
| Smart Payment Buttons | https://developer.paypal.com/docs/checkout/ |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.