PayPal Checkout skill for Codex

Server-side PayPal orders that capture exactly once. Codex wires PayPal buttons to server endpoints that build the order from your own cart, capture it, and only fulfil when a verified PAYMENT.CAPTURE.COMPLETED webhook arrives. It caches OAuth tokens, tags each create and capture with PayPal-Request-Id, splices the raw event into the signature check so verification does not fail, and runs the flow with sandbox buyers.

  • Integrations
  • $paypal-integration
  • 204 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- paypal-integration

Installs to ~/.agents/skills/paypal-integration/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $paypal-integration, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Integrates PayPal Checkout with the JavaScript SDK buttons and the Orders v2 REST API - server-side order creation and capture, OAuth client-credentials tokens, PayPal-Request-Id idempotency, webhook signature verification and sandbox testing. Use when adding PayPal or Pay Later buttons, moving from client-side order creation to server-side capture, handling PAYMENT.CAPTURE.COMPLETED or refund webhooks, or debugging PayPal sandbox versus live credential errors.

Use it when

  • Adding PayPal or Pay Later as a payment option
  • Moving PayPal capture from the browser to your server
  • PayPal webhook verification fails or orders are fulfilled twice
  • Migrating a legacy PayPal v1 or checkout.js integration

Use something else when

How to check the result

A sandbox purchase reaches capture status COMPLETED, the webhook verifies with SUCCESS, and replaying the same webhook does not fulfil the order a second time.

The mistake it prevents

Capturing the order in the browser with actions.order.capture, which lets a tampered client change the amount and skips server-side checks.

~/.agents/skills/paypal-integration/SKILL.md

--- name: paypal-integration description: Integrates PayPal Checkout with the JavaScript SDK buttons and the Orders v2 REST API - server-side order creation and capture, OAuth… license: MIT ---

Done well means: the server creates the order with amounts it calculated, the server captures it and checks the capture status, webhooks are verified before they change anything, every create and capture call carries a PayPal-Request-Id, and the full flow has run in the sandbox with a sandbox buyer account.

Before writing code

  1. Read AGENTS.md; find existing PayPal code. Legacy integrations (checkout.js, Express Checkout NVP, Payments v1 /v1/payments/payment) should be migrated, not extended.
  2. Confirm the environment. Sandbox base URL https://api-m.sandbox.paypal.com, live https://api-m.paypal.com. Sandbox and live client IDs and secrets are different apps.
  3. Store PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID in env vars. The client ID is public; the secret never reaches the browser.
  4. Captures and refunds in live mode, and any change to the live app’s settings, need explicit user confirmation.

PayPal API calls need network access; in a Codex sandbox, ask for approval or mock the HTTP client in tests.

Flow

Browser (PayPal button) --createOrder--> your server --POST /v2/checkout/orders--> PayPal
Buyer approves in PayPal popup
Browser --onApprove(orderID)--> your server --POST /v2/checkout/orders/{id}/capture--> PayPal
PayPal --webhook PAYMENT.CAPTURE.COMPLETED--> your server (source of truth for fulfilment)

Access token

const BASE = process.env.PAYPAL_BASE_URL!; // sandbox or live
let cached: { token: string; exp: number } | null = null;

async function accessToken() {
  if (cached && Date.now() < cached.exp) return cached.token;
  const auth = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString("base64");
  const res = await fetch(`${BASE}/v1/oauth2/token`, {
    method: "POST",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/x-www-form-urlencoded" },
    body: "grant_type=client_credentials",
  });
  if (!res.ok) throw new Error(`PayPal auth ${res.status}`);
  const json = await res.json();
  cached = { token: json.access_token, exp: Date.now() + (json.expires_in - 60) * 1000 };
  return cached.token;
}

Cache the token until shortly before expires_in; requesting one per call is slow and can hit limits. PayPal also publishes a server SDK (@paypal/paypal-server-sdk); plain REST is shown here because it is stable across languages.

Create and capture

app.post("/api/paypal/orders", async (req, res) => {
  const cart = await loadCart(req.session.cartId);          // never trust browser amounts
  const r = await fetch(`${BASE}/v2/checkout/orders`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await accessToken()}`,
      "Content-Type": "application/json",
      "PayPal-Request-Id": `create-${cart.id}`,            // idempotent retries
    },
    body: JSON.stringify({
      intent: "CAPTURE",
      purchase_units: [{
        reference_id: cart.id,
        custom_id: cart.orderId,                              // echoed in webhooks
        amount: { currency_code: "USD", value: cart.total.toFixed(2) },
      }],
    }),
  });
  const order = await r.json();
  res.status(r.status).json({ id: order.id });
});

app.post("/api/paypal/orders/:id/capture", async (req, res) => {
  const r = await fetch(`${BASE}/v2/checkout/orders/${req.params.id}/capture`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await accessToken()}`,
      "Content-Type": "application/json",
      "PayPal-Request-Id": `capture-${req.params.id}`,
    },
  });
  const data = await r.json();
  const capture = data.purchase_units?.[0]?.payments?.captures?.[0];
  if (data.details?.[0]?.issue === "INSTRUMENT_DECLINED") return res.status(422).json({ retry: true });
  res.json({ status: data.status, captureStatus: capture?.status }); // COMPLETED or PENDING
});
  • amount.value is a string with the currency’s decimals: two for USD and EUR, zero for JPY and HUF. Itemised orders need breakdown.item_total that equals the sum of items, or PayPal rejects the order with ITEM_TOTAL_MISMATCH.
  • A capture can be PENDING (eCheck, review). Do not fulfil until COMPLETED arrives.
  • INSTRUMENT_DECLINED on capture: tell the client to call actions.restart() so the buyer picks another funding source.

Buttons (client)

<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=USD&intent=capture"></script>
<div id="paypal-buttons"></div>
<script>
  paypal.Buttons({
    createOrder: () => fetch("/api/paypal/orders", { method: "POST" }).then(r => r.json()).then(o => o.id),
    onApprove: async (data, actions) => {
      const r = await fetch(`/api/paypal/orders/${data.orderID}/capture`, { method: "POST" });
      const j = await r.json();
      if (j.retry) return actions.restart();
      showResult(j);
    },
    onError: (err) => showError(err),
  }).render("#paypal-buttons");
</script>

The SDK currency and intent query parameters must match the order you create, or the button fails with a currency or intent mismatch.

Webhooks

Subscribe the app’s webhook to the events you handle, typically PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.PENDING, PAYMENT.CAPTURE.REFUNDED and CUSTOMER.DISPUTE.CREATED. Verify with PayPal’s verification endpoint:

app.post("/webhooks/paypal", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  const h = (k: string) => req.header(k) ?? "";
  // Splice the raw event in unchanged; re-serialising can alter it and fail verification.
  const body = `{"auth_algo":${JSON.stringify(h("paypal-auth-algo"))},` +
    `"cert_url":${JSON.stringify(h("paypal-cert-url"))},` +
    `"transmission_id":${JSON.stringify(h("paypal-transmission-id"))},` +
    `"transmission_sig":${JSON.stringify(h("paypal-transmission-sig"))},` +
    `"transmission_time":${JSON.stringify(h("paypal-transmission-time"))},` +
    `"webhook_id":${JSON.stringify(process.env.PAYPAL_WEBHOOK_ID)},` +
    `"webhook_event":${raw}}`;
  const v = await fetch(`${BASE}/v1/notifications/verify-webhook-signature`, {
    method: "POST",
    headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json" },
    body,
  }).then(r => r.json());
  if (v.verification_status !== "SUCCESS") return res.sendStatus(400);

  const event = JSON.parse(raw);
  if (await seen(event.id)) return res.sendStatus(200);
  if (event.event_type === "PAYMENT.CAPTURE.COMPLETED") {
    await markPaid(event.resource.custom_id, event.resource.id);   // idempotent on order status
  }
  res.sendStatus(200);
});

Sandbox and live apps have different webhook IDs. Use the sandbox Webhooks simulator in the developer dashboard for synthetic events; note that simulator events cannot be verified with the endpoint above, so test verification with real sandbox purchases.

Refunds

POST /v2/payments/captures/{capture_id}/refund with optional amount and a PayPal-Request-Id. Confirm order, amount and reason with the user before a live refund.

Gotchas

  • Capturing on the client with actions.order.capture(): amounts and fulfilment can be tampered with. Capture on the server.
  • Storing only the order ID: refunds need the capture ID (purchase_units[0].payments.captures[0].id).
  • Floating-point totals: build value from integer cents.
  • Sandbox personal account email confusion: the buyer must log in with a sandbox personal account from the developer dashboard, never a real PayPal login.
  • Orders expire if not captured (approved orders are valid for a limited time); handle ORDER_NOT_APPROVED and RESOURCE_NOT_FOUND by starting a new order.

Verification

  • Sandbox purchase: order created server-side, captured, capture status COMPLETED, webhook verified with SUCCESS, order marked paid once.
  • Replay the same webhook body: no second fulfilment.
  • Decline path: use a sandbox card that fails, confirm the buyer can choose another source.
  • Refund in sandbox updates the order state via PAYMENT.CAPTURE.REFUNDED.

Report template

**Integration:** JS SDK buttons + Orders v2, intent CAPTURE, server-side create/capture
**Idempotency:** PayPal-Request-Id on create (cart id) and capture (order id)
**Webhooks:** POST /webhooks/paypal, verified via verify-webhook-signature; events: PAYMENT.CAPTURE.*
**Stored:** paypal_order_id, paypal_capture_id, status
**Tested (sandbox):** success, decline + restart, replayed webhook, refund
**Needs user:** live app credentials, live webhook ID

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "PayPal Checkout"
  short_description: "Server-side PayPal orders that capture exactly once"
  default_prompt: "$paypal-integration Add PayPal buttons next to our card checkout and capture the order on the server"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem