Skill: ivangdavila/paypal (version 1.0.0)

--- name: PayPal slug: paypal version: 1.0.0 description: Integrate PayPal payments with proper webhook verification, OAuth handling, and security validation for checkout flows and subscriptions. metadata: {"clawdbot":{"emoji":"💳","requires":{"bins":[]},"os":["linux","darwin","win32"]}} --- ## When to Use User needs to integrate PayPal REST API for payments, subscriptions, or payouts. Agent handles checkout flows, webhook verification, OAuth token management, and dispute workflows. ## Quick Reference | Topic | File | |-------|------| | Code patterns | `patterns.md` | | Webhook events | `webhooks.md` | ## Core Rules ### 1. Environment URLs are Different - Sandbox: `api.sandbox.paypal.com` - Production: `api.paypal.com` - Ask which environment BEFORE generating code - Credentials are environment-specific — never mix ### 2. OAuth Token Management ```javascript // Token expires ~8 hours — handle refresh const getToken = async () => { const res = await fetch('https://api.paypal.com/v1/oauth2/token', { method: 'POST', headers: { 'Authorization': `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`, 'Content-Type': [REDACTED_SECRET] }, body: 'grant_type=client_credentials' }); return res.json(); // { access_token, expires_in } }; ``` Never hardcode tokens. Implement refresh logic. ### 3. Webhook Verification is Mandatory PayPal webhooks MUST be verified via API call — not simple HMAC: ```javascript // POST /v1/notifications/verify-webhook-signature const verification = await fetch('https://api.paypal.com/v1/notifications/verify-webhook-signature', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_algo: headers['paypal-auth-algo'], cert_url: headers['paypal-cert-url'], transmission_id: headers['paypal-transmission-id'], transmission_sig: headers['paypal-transmission-sig'], transmission_time: headers['paypal-transmission-time'], webhook_id: WEBHOOK_ID, webhook_event: body }) }); // verification_status === 'SUCCESS' ``` ### 4. CAPTURE vs AUTHORIZE — Ask First | Intent | Behavior | |--------|----------| | `CAPTURE` | Charges immediately on approval | | `AUTHORIZE` | Reserves funds, capture later (up to 29 days) | Changing intent after integration breaks the entire flow. ### 5. Server-Side Validation — Never Trust Client ```javascript // After client approves, VERIFY on server before fulfillment const order = await fetch(`https://api.paypal.com/v2/checkout/orders/${orderId}`, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()); // Validate ALL of these: if (order.status !== 'APPROVED') throw new Error('Not approved'); if (order.purchase_units[0].amount.value !== expectedAmount) throw new Error('Amount mismatch'); if (order.purchase_units[0].amount.currency_code !== expectedCurrency) throw new Error('Currency mismatch'); if (order.purchase_units[0].payee.merchant_id !== YOUR_MERCHANT_ID) throw new Error('Wrong merchant'); ``` ### 6. Idempotency in Webhooks PayPal may send the same webhook multiple times: ```javascript const processed = await db.webhooks.findOne({ eventId: body.id }); if (processed) return res.status(200).send('Already processed'); await db.webhooks.insert({ eventId: body.id, processedAt: new Date() }); // Now process the event ``` ### 7. Currency Decimal Rules Some currencies have NO decimal places: | Currency | Decimals | Example | |----------|----------|---------| | USD, EUR | 2 | "10.50" | | JPY, TWD | 0 | "1050" (NOT "1050.00") | Sending "10.50" for JPY = API error. ## Common Traps - **IPN vs Webhooks** — IPN is legacy. Use Webhooks for new integrations. Never mix. - **Order states** — CREATED → APPROVED → COMPLETED (or VOIDED). Handle ALL states, not just happy path. - **Decimal confusion** — PayPal uses strings for amounts ("10.50"), not floats. Some currencies forbid decimals. - **Sandbox rate limits** — Lower than production. Don't assume prod will fail the same way. - **Payout vs Payment** — Payouts API is separate. Don't confuse sending money (Payouts) with receiving (Orders).

path: patterns.md
content: # Code Patterns — PayPal Integration

## Create Order (Server)

```javascript
const createOrder = async (amount, currency = 'USD') => {
  const token = await getToken();
  const res = await fetch('https://api.paypal.com/v2/checkout/orders', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      intent: 'CAPTURE',
      purchase_units: [{
        amount: { currency_code: currency, value: amount.toFixed(2) },
        custom_id: 'your-internal-order-id' // For reconciliation
      }]
    })
  });
  return res.json(); // { id, status, links }
};
```

## Capture Payment (After Approval)

```javascript
const captureOrder = async (orderId) => {
  const token = await getToken();
  
  // First verify the order
  const order = await fetch(`https://api.paypal.com/v2/checkout/orders/${orderId}`, {
    headers: { 'Authorization': `Bearer ${token}` }
  }).then(r => r.json());
  
  if (order.status !== 'APPROVED') {
    throw new Error(`Invalid order status: ${order.status}`);
  }
  
  // Then capture
  const capture = await fetch(`https://api.paypal.com/v2/checkout/orders/${orderId}/capture`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
  });
  return capture.json();
};
```

## Frontend (JavaScript SDK)

```html
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=USD"></script>
<div id="paypal-button-container"></div>
<script>
  paypal.Buttons({
    createOrder: () => {
      return fetch('/api/paypal/create-order', { method: 'POST' })
        .then(res => res.json())
        .then(data => data.id);
    },
    onApprove: (data) => {
      return fetch('/api/paypal/capture-order', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ orderId: data.orderID })
      }).then(res => res.json())
        .then(details => {
          alert('Payment completed!');
        });
    },
    onError: (err) => {
      console.error('PayPal error:', err);
    }
  }).render('#paypal-button-container');
</script>
```

## Subscription (Billing)

```javascript
// Create product first
const product = await fetch('https://api.paypal.com/v1/catalogs/products', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Premium Plan',
    type: 'SERVICE'
  })
}).then(r => r.json());

// Then create plan
const plan = await fetch('https://api.paypal.com/v1/billing/plans', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    product_id: product.id,
    name: 'Monthly Premium',
    billing_cycles: [{
      frequency: { interval_unit: 'MONTH', interval_count: 1 },
      tenure_type: 'REGULAR',
      sequence: 1,
      total_cycles: 0, // Infinite
      pricing_scheme: { fixed_price: { value: '9.99', currency_code: 'USD' } }
    }],
    payment_preferences: {
      auto_bill_outstanding: true,
      payment_failure_threshold: 3
    }
  })
}).then(r => r.json());
```

## Refund

```javascript
const refundCapture = async (captureId, amount = null) => {
  const token = await getToken();
  const body = amount ? { amount: { value: amount, currency_code: 'USD' } } : {};
  
  return fetch(`https://api.paypal.com/v2/payments/captures/${captureId}/refund`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  }).then(r => r.json());
};
```


path: webhooks.md
content: # Webhook Events — PayPal Integration

## Essential Events to Subscribe

| Event | When | Action |
|-------|------|--------|
| `PAYMENT.CAPTURE.COMPLETED` | Payment captured successfully | Fulfill order |
| `PAYMENT.CAPTURE.DENIED` | Capture failed | Cancel order, notify user |
| `PAYMENT.CAPTURE.REFUNDED` | Refund processed | Update order status |
| `CHECKOUT.ORDER.APPROVED` | User approved in PayPal | Capture the payment |
| `BILLING.SUBSCRIPTION.ACTIVATED` | Subscription started | Enable premium access |
| `BILLING.SUBSCRIPTION.CANCELLED` | User cancelled | Disable at period end |
| `BILLING.SUBSCRIPTION.SUSPENDED` | Payment failed | Notify user, retry |
| `CUSTOMER.DISPUTE.CREATED` | Dispute opened | Alert team immediately |
| `CUSTOMER.DISPUTE.RESOLVED` | Dispute closed | Update records |

## Webhook Handler Structure

```javascript
app.post('/webhooks/paypal', async (req, res) => {
  const headers = req.headers;
  const body = req.body;
  
  // 1. Verify signature (MANDATORY)
  const verified = await verifyWebhook(headers, body);
  if (!verified) {
    return res.status(401).send('Invalid signature');
  }
  
  // 2. Check idempotency
  const exists = await db.webhooks.findOne({ eventId: body.id });
  if (exists) {
    return res.status(200).send('Already processed');
  }
  
  // 3. Process by event type
  switch (body.event_type) {
    case 'PAYMENT.CAPTURE.COMPLETED':
      await handleCaptureCompleted(body.resource);
      break;
    case 'CUSTOMER.DISPUTE.CREATED':
      await handleDisputeCreated(body.resource);
      break;
    // ... other events
  }
  
  // 4. Record processing
  await db.webhooks.insert({ eventId: body.id, processedAt: new Date() });
  
  res.status(200).send('OK');
});
```

## Dispute Handling

Disputes have strict deadlines:

| Stage | Deadline | Action Required |
|-------|----------|-----------------|
| INQUIRY | 20 days | Respond or escalates to CLAIM |
| CLAIM | 10 days | Provide evidence or lose |
| APPEAL | 10 days | Final chance if lost |

```javascript
const handleDisputeCreated = async (dispute) => {
  const deadline = new Date(dispute.create_time);
  deadline.setDate(deadline.getDate() + 10);
  
  await notifyTeam({
    type: 'DISPUTE',
    priority: 'HIGH',
    disputeId: dispute.dispute_id,
    amount: dispute.dispute_amount.value,
    reason: dispute.reason,
    deadline: deadline
  });
  
  // Gather evidence automatically
  const order = await db.orders.findOne({ 
    paypalOrderId: dispute.disputed_transactions[0].reference_id 
  });
  
  if (order.trackingNumber) {
    // You can submit evidence via API
    // POST /v1/customer/disputes/{dispute_id}/provide-evidence
  }
};
```

## Testing Webhooks (Sandbox)

PayPal provides webhook simulator in sandbox:
1. Go to Developer Dashboard → Webhooks
2. Click "Simulate Event"
3. Select event type
4. Your endpoint receives test payload

Or use ngrok for local development:
```bash
ngrok http 3000
# Configure webhook URL: https://abc123.ngrok.io/webhooks/paypal
```


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)