Skip to content

biggora/paypal

Repository files navigation

@biggora/paypal

npm version Unit Tests License: MIT

TypeScript SDK and NestJS adapter for the PayPal REST API.

Targets Node.js 20+. Ships dual ESM/CommonJS builds and a first-class NestJS subpath export. All request and response bodies are strictly typed from PayPal's official OpenAPI specifications.

Install

npm install @biggora/paypal

NestJS apps also need peer deps:

npm install @nestjs/common @nestjs/core reflect-metadata rxjs

Quick Start: Checkout

Create an order with intent CAPTURE, redirect the buyer to the approval link, then capture the order once the buyer approves.

import { createPayPalClient } from '@biggora/paypal';

const paypal = createPayPalClient({
  clientId: process.env.PAYPAL_CLIENT_ID!,
  clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
  environment: 'sandbox',
});

const order = await paypal.orders.create({
  intent: 'CAPTURE',
  purchase_units: [
    {
      reference_id: 'order-1001',
      amount: {
        currency_code: 'USD',
        value: '49.99',
      },
    },
  ],
  payment_source: {
    paypal: {
      experience_context: {
        return_url: 'https://shop.example/checkout/success',
        cancel_url: 'https://shop.example/checkout/cancel',
      },
    },
  },
});

const approveLink = order.links?.find((link) => link.rel === 'payer-action');
console.log(approveLink?.href);

// After the buyer approves, capture the funds.
const captured = await paypal.orders.capture(order.id!);
console.log(captured.status); // COMPLETED

OAuth2 (client_credentials) authentication and access-token caching are handled automatically on the first request.

Configuration

createPayPalClient(options) accepts:

Option Type Default Description
clientId string PayPal REST app client ID (required).
clientSecret string PayPal REST app secret (required).
environment 'sandbox' | 'live' 'sandbox' Selects the PayPal base URL.
baseUrl string Overrides the base URL. Takes precedence over environment.
webhookId string Default webhook ID used by verifyWebhook.
timeoutMs number 30000 Per-request timeout in milliseconds.
fetch FetchLike global fetch Custom fetch implementation for testing or proxies.

Base URLs: sandbox https://api-m.sandbox.paypal.com, live https://api-m.paypal.com.

The client obtains an OAuth2 token via client_credentials and caches it until shortly before expiry, refreshing transparently. Call paypal.getAccessToken() to read the current token or paypal.clearAccessToken() to force a refresh.

Orders

const order = await paypal.orders.create({
  intent: 'AUTHORIZE',
  purchase_units: [{ amount: { currency_code: 'USD', value: '25.00' } }],
});

const fetched = await paypal.orders.get(order.id!);

await paypal.orders.update(order.id!, [
  { op: 'replace', path: "/purchase_units/@reference_id=='default'/amount", value: { currency_code: 'USD', value: '30.00' } },
]);

await paypal.orders.authorize(order.id!);
await paypal.orders.confirmPaymentSource(order.id!, {
  payment_source: { paypal: {} },
});

// Add shipment tracking after capture.
await paypal.orders.track(order.id!, {
  capture_id: 'CAPTURE-ID',
  tracking_number: '1Z9999',
  carrier: 'UPS',
});
await paypal.orders.updateTracking(order.id!, 'TRACKER-ID', [
  { op: 'replace', path: '/status', value: 'SHIPPED' },
]);

Payments & Refunds

Work with the authorizations, captures, and refunds created by orders.

// Authorizations
const auth = await paypal.payments.getAuthorization('AUTH-ID');
const capture = await paypal.payments.captureAuthorization('AUTH-ID', {
  amount: { currency_code: 'USD', value: '49.99' },
  final_capture: true,
});
await paypal.payments.reauthorizeAuthorization('AUTH-ID');
await paypal.payments.voidAuthorization('AUTH-ID');

// Captures and refunds
const settled = await paypal.payments.getCapture(capture.id!);
const refund = await paypal.payments.refundCapture(capture.id!, {
  amount: { currency_code: 'USD', value: '49.99' },
  note_to_payer: 'Order canceled',
});
const refundDetails = await paypal.payments.getRefund(refund.id!);

Subscriptions & Plans

// Catalog plan
const plan = await paypal.plans.create({
  product_id: 'PROD-XXXX',
  name: 'Pro Monthly',
  billing_cycles: [
    {
      frequency: { interval_unit: 'MONTH', interval_count: 1 },
      tenure_type: 'REGULAR',
      sequence: 1,
      total_cycles: 0,
      pricing_scheme: { fixed_price: { currency_code: 'USD', value: '19.99' } },
    },
  ],
  payment_preferences: { auto_bill_outstanding: true },
});

await paypal.plans.activate(plan.id!);
const plans = await paypal.plans.list({ page_size: 20 });

// Subscription
const subscription = await paypal.subscriptions.create({
  plan_id: plan.id!,
  subscriber: { email_address: 'customer@example.com' },
});

await paypal.subscriptions.suspend(subscription.id!, { reason: 'Customer request' });
await paypal.subscriptions.activate(subscription.id!, { reason: 'Reactivated' });
await paypal.subscriptions.cancel(subscription.id!, { reason: 'No longer needed' });

const transactions = await paypal.subscriptions.listTransactions(subscription.id!, {
  start_time: '2026-01-01T00:00:00Z',
  end_time: '2026-02-01T00:00:00Z',
});

Payouts

const batch = await paypal.payouts.create({
  sender_batch_header: {
    sender_batch_id: 'batch-2026-05-01',
    email_subject: 'You have a payout!',
  },
  items: [
    {
      recipient_type: 'EMAIL',
      amount: { currency: 'USD', value: '10.00' },
      receiver: 'receiver@example.com',
      note: 'Thanks for your business.',
    },
  ],
});

const batchId = batch.batch_header?.payout_batch_id!;
const status = await paypal.payouts.get(batchId, { page_size: 100 });

const item = await paypal.payouts.getItem('ITEM-ID');
await paypal.payouts.cancelItem('ITEM-ID');

Catalog Products

const product = await paypal.products.create({
  name: 'Video Streaming Service',
  type: 'SERVICE',
  category: 'SOFTWARE',
});

const products = await paypal.products.list({ page_size: 10 });
const fetched = await paypal.products.get(product.id!);

await paypal.products.update(product.id!, [
  { op: 'replace', path: '/description', value: 'Premium plan' },
]);

Invoicing

const number = await paypal.invoices.generateInvoiceNumber();

const invoice = await paypal.invoices.create({
  detail: {
    invoice_number: number.invoice_number,
    currency_code: 'USD',
  },
  items: [
    {
      name: 'Consulting',
      quantity: '1',
      unit_amount: { currency_code: 'USD', value: '500.00' },
    },
  ],
});

await paypal.invoices.send(invoice.href!.split('/').pop()!, { send_to_invoicer: true });
await paypal.invoices.remind('INV-ID', { subject: 'Payment reminder' });
await paypal.invoices.recordPayment('INV-ID', {
  method: 'BANK_TRANSFER',
  amount: { currency_code: 'USD', value: '500.00' },
});

const qr = await paypal.invoices.generateQrCode('INV-ID', { width: 300, height: 300 });
const list = await paypal.invoices.list({ page_size: 20 });

invoices.update performs a full replace (HTTP PUT). Other helpers: delete, cancel, recordRefund.

Webhooks

Register a webhook once per environment, then verify each incoming delivery against PayPal's verify-webhook-signature API.

const webhook = await paypal.webhooks.create({
  url: 'https://shop.example/paypal/webhook',
  event_types: [
    { name: 'CHECKOUT.ORDER.APPROVED' },
    { name: 'PAYMENT.CAPTURE.COMPLETED' },
    { name: 'PAYMENT.CAPTURE.REFUNDED' },
  ],
});

console.log(webhook.id); // store as PAYPAL_WEBHOOK_ID

Verify and parse the event in an Express handler using the raw request body:

import express from 'express';
import { createPayPalClient, parseWebhookEvent } from '@biggora/paypal';

const paypal = createPayPalClient({
  clientId: process.env.PAYPAL_CLIENT_ID!,
  clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
  webhookId: process.env.PAYPAL_WEBHOOK_ID!,
});

const app = express();

app.post('/paypal/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = parseWebhookEvent(req.body as Buffer);

  const { verification_status } = await paypal.verifyWebhook({
    headers: req.headers,
    event,
  });

  if (verification_status !== 'SUCCESS') {
    return res.status(400).send('Invalid signature');
  }

  if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
    // fulfill the order from event.resource
  }

  return res.sendStatus(200);
});

verifyWebhook reads webhookId from the parameter or from PayPalClientOptions.webhookId. The lower-level helpers extractTransmissionHeaders(headers) and buildVerifyWebhookPayload({ headers, webhookId, event }) are exported for custom flows, and paypal.webhooks.verifySignature(payload) calls the API directly.

NestJS

Use the @biggora/paypal/nestjs subpath export.

import { Module } from '@nestjs/common';
import { PayPalModule } from '@biggora/paypal/nestjs';

@Module({
  imports: [
    PayPalModule.forRoot({
      clientId: process.env.PAYPAL_CLIENT_ID!,
      clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
      environment: 'sandbox',
      webhookId: process.env.PAYPAL_WEBHOOK_ID,
    }),
  ],
})
export class PaymentsModule {}

Async config keeps credentials in your existing config service:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PayPalModule } from '@biggora/paypal/nestjs';

@Module({
  imports: [
    ConfigModule.forRoot(),
    PayPalModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (config: ConfigService) => ({
        clientId: config.getOrThrow<string>('PAYPAL_CLIENT_ID'),
        clientSecret: config.getOrThrow<string>('PAYPAL_CLIENT_SECRET'),
        environment: config.get<'sandbox' | 'live'>('PAYPAL_ENV') ?? 'sandbox',
        webhookId: config.get<string>('PAYPAL_WEBHOOK_ID'),
      }),
    }),
  ],
})
export class PaymentsModule {}

Inject the client into services:

import { Injectable } from '@nestjs/common';
import { InjectPayPalClient } from '@biggora/paypal/nestjs';
import type { PayPalClient } from '@biggora/paypal';

@Injectable()
export class CheckoutService {
  constructor(@InjectPayPalClient() private readonly paypal: PayPalClient) {}

  capture(orderId: string) {
    return this.paypal.orders.capture(orderId);
  }
}

You can also inject by token with @Inject(PAYPAL_CLIENT).

Verify webhooks with the provided PayPalWebhookVerifier. Enable raw body so signatures can be validated:

const app = await NestFactory.create(AppModule, { rawBody: true });
import { Controller, Post, Req } from '@nestjs/common';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { PayPalWebhookVerifier } from '@biggora/paypal/nestjs';

@Controller('paypal')
export class PayPalWebhookController {
  constructor(private readonly verifier: PayPalWebhookVerifier) {}

  @Post('webhook')
  async webhook(@Req() req: RawBodyRequest<Request>) {
    const rawBody = req.rawBody ?? Buffer.alloc(0);
    const event = this.verifier.parse(rawBody);

    const verified = await this.verifier.isVerified({
      headers: req.headers,
      event,
    });

    if (!verified) {
      return { ok: false };
    }

    // handle event.event_type
    return { ok: true };
  }
}

PayPalWebhookVerifier exposes parse(rawBody), verify({ headers, event, webhookId? }), and isVerified({ headers, event, webhookId? }): Promise<boolean>.

Error Handling

Every failed request throws PayPalApiError.

import { PayPalApiError } from '@biggora/paypal';

try {
  await paypal.orders.capture('missing-order-id');
} catch (error) {
  if (error instanceof PayPalApiError) {
    console.error(error.status);   // HTTP status code
    console.error(error.code);     // PayPal error name, e.g. RESOURCE_NOT_FOUND
    console.error(error.message);  // human-readable message
    console.error(error.debugId);  // PayPal debug ID for support
    console.error(error.details);  // structured error details
    console.error(error.links);    // HATEOAS links
  }
}

Type Safety

All request bodies, query parameters, and responses are derived from PayPal's official OpenAPI specifications (the paypal/paypal-rest-api-specifications repository). Regenerate the typings with:

npm run generate:types

Per-domain schema namespaces are re-exported for building your own typed payloads:

import type { OrdersSchema, PaymentsSchema, SubscriptionsSchema } from '@biggora/paypal';

type OrderRequest = OrdersSchema.components['schemas']['order_request'];

Available namespaces: OrdersSchema, PaymentsSchema, SubscriptionsSchema, ProductsSchema, InvoicingSchema, PayoutsSchema, WebhooksSchema.

Scripts

npm run build           # bundle ESM + CJS with tsup
npm test                # run the vitest suite
npm run typecheck       # tsc --noEmit
npm run generate:types  # regenerate OpenAPI typings from PayPal specs

License

MIT

About

TypeScript SDK and NestJS adapter for the PayPal REST API.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors