PayPal REST API — Backbone Integration buat Developer Indonesia
Lo developer Indonesia. Mau integrate PayPal checkout ke web app lo. Pakai WordPress plugin? Terlalu kaku. Pakai third-party gateway? Mahal. Solusinya: direct REST API integration.
PayPal REST API powerful, well-documented, dan punya semua feature yang lo butuh: one-time payment, subscription, refund, payout, dispute, invoicing.
Panduan ini bahas cara integrate PayPal REST API di Next.js 16 dengan TypeScript.
Singkatnya: PayPal REST API = OAuth2 + endpoints + webhook. Next.js 16 API route handle server-side, client SDK buat button. Butuh bantu integrate? Chat ChatBot Cell.
1. Konsep Dasar PayPal REST API
Arsitektur
[Browser Client]
↓ (PayPal Smart Button SDK)
[PayPal JS SDK]
↓ (HTTPS)
[PayPal API Gateway]
↑ (server-to-server, OAuth2 token)
[Your Next.js API Route]
↑
[Your DB]
Komponen Utama
- Client SDK (
@paypal/react-paypal-js): render button di browser - Server API Route (
/app/api/paypal/...): OAuth2, create order, capture, webhook - Webhook Handler (
/app/api/paypal/webhook/route.ts): receive event async - Database: store order, payment, subscription state
Environment
| Env | Base URL | Pakai Kapan |
|---|---|---|
| Sandbox | api-m.sandbox.paypal.com |
Dev + test |
| Live | api-m.paypal.com |
Production |
2. Setup Project
Install Dependency
npm install @paypal/react-paypal-js
npm install -D typescript @types/node
Environment Variables
# .env.local
PAYPAL_ENV=sandbox
PAYPAL_CLIENT_ID=ATmxZxxx
PAYPAL_CLIENT_SECRET=EGj3xxx
PAYPAL_WEBHOOK_ID=WH-XXXXX
PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
NEXT_PUBLIC_PAYPAL_CLIENT_ID=ATmxZxxx
TypeScript Types
// types/paypal.ts
export interface PayPalOrder {
id: string;
status: "CREATED" | "SAVED" | "APPROVED" | "VOIDED" | "COMPLETED";
intent: "CAPTURE" | "AUTHORIZE";
purchase_units: PurchaseUnit[];
links: PayPalLink[];
}
export interface PurchaseUnit {
amount: { currency_code: string; value: string };
description?: string;
reference_id?: string;
}
export interface PayPalLink {
href: string;
rel: string;
method: string;
}
3. OAuth2 Token Management
Server-Side Token Cache
// lib/paypal/auth.ts
const tokenCache = { token: "", expiresAt: 0 };
export async function getAccessToken(): Promise<string> {
if (tokenCache.token && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.token;
}
const auth = Buffer.from(
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
).toString("base64");
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/v1/oauth2/token`,
{
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
cache: "no-store",
}
);
if (!res.ok) {
throw new Error(`PayPal auth failed: ${res.status}`);
}
const data = await res.json();
tokenCache.token = data.access_token;
tokenCache.expiresAt = Date.now() + data.expires_in * 1000;
return data.access_token;
}
⚠ Critical: Pakai in-memory cache untuk single-instance. Kalau multi-instance (Vercel, Kubernetes), pakai Redis atau Upstash untuk shared cache.
4. Create Order Endpoint
API Route
// app/api/paypal/create-order/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAccessToken } from "@/lib/paypal/auth";
export async function POST(req: NextRequest) {
const { items, currency = "USD" } = await req.json();
const total = items.reduce(
(sum: number, item: { amount: number; quantity: number }) =>
sum + item.amount * item.quantity,
0
);
const token = await getAccessToken();
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/v2/checkout/orders`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"PayPal-Request-Id": crypto.randomUUID(),
},
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: {
currency_code: currency,
value: total.toFixed(2),
},
description: `Order ${Date.now()}`,
items: items.map((item: { name: string; amount: number; quantity: number }) => ({
name: item.name,
unit_amount: {
currency_code: currency,
value: item.amount.toFixed(2),
},
quantity: String(item.quantity),
})),
},
],
}),
}
);
const order = await res.json();
if (!res.ok) {
return NextResponse.json({ error: order }, { status: 500 });
}
return NextResponse.json({ id: order.id });
}
5. Capture Order Endpoint
// app/api/paypal/capture-order/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAccessToken } from "@/lib/paypal/auth";
import { updateOrderStatus } from "@/lib/db";
export async function POST(req: NextRequest) {
const { orderID } = await req.json();
const token = await getAccessToken();
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/v2/checkout/orders/${orderID}/capture`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
const captureData = await res.json();
if (!res.ok) {
return NextResponse.json({ error: captureData }, { status: 500 });
}
// Update DB (defensive: webhook juga akan trigger update)
await updateOrderStatus(orderID, "PAID", captureData);
return NextResponse.json({ status: "success", capture: captureData });
}
6. Client Component (Smart Button)
// app/components/PayPalCheckout.tsx
"use client";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
export default function PayPalCheckout({ items, total }) {
return (
<PayPalScriptProvider
options={{
clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!,
currency: "USD",
intent: "capture",
}}
>
<PayPalButtons
style={{ layout: "vertical", color: "gold", shape: "rect" }}
createOrder={async () => {
const res = await fetch("/api/paypal/create-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
const { id } = await res.json();
return id;
}}
onApprove={async (data) => {
const res = await fetch("/api/paypal/capture-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderID: data.orderID }),
});
const result = await res.json();
if (result.status === "success") {
window.location.href = "/order/success";
} else {
alert("Payment failed. Coba lagi.");
}
}}
onError={(err) => {
console.error("PayPal error:", err);
alert("Terjadi kesalahan. Hubungi support.");
}}
/>
</PayPalScriptProvider>
);
}
7. Webhook Handler
Promo seru yang cocok buat kamu
Penawaran pilihan dari mitra kami — klik buat lihat detail.
Mengandung link afiliasi. Baca disclaimer.
// app/api/paypal/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAccessToken } from "@/lib/paypal/auth";
import { handleWebhookEvent } from "@/lib/paypal/webhook-handler";
export async function POST(req: NextRequest) {
const body = await req.text();
const headers = Object.fromEntries(req.headers.entries());
const verified = await verifyWebhookSignature(headers, JSON.parse(body));
if (!verified) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(body);
// Idempotent handling
const alreadyProcessed = await checkProcessedEvent(event.id);
if (alreadyProcessed) {
return NextResponse.json({ status: "duplicate" });
}
try {
await handleWebhookEvent(event);
await markEventProcessed(event.id);
} catch (err) {
console.error("Webhook handler error:", err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
return NextResponse.json({ status: "success" });
}
async function verifyWebhookSignature(headers: Record<string, string>, body: any) {
const token = await getAccessToken();
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/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: process.env.PAYPAL_WEBHOOK_ID,
webhook_event: body,
}),
}
);
const data = await res.json();
return data.verification_status === "SUCCESS";
}
8. Webhook Event Handler
// lib/paypal/webhook-handler.ts
import { updateOrderStatus, activateSubscription, refundProcessed } from "@/lib/db";
export async function handleWebhookEvent(event: any) {
switch (event.event_type) {
case "PAYMENT.CAPTURE.COMPLETED":
await updateOrderStatus(event.resource.id, "PAID", event.resource);
await sendConfirmationEmail(event.resource.custom_id);
break;
case "PAYMENT.CAPTURE.DENIED":
await updateOrderStatus(event.resource.id, "DENIED", event.resource);
break;
case "PAYMENT.CAPTURE.REFUNDED":
await refundProcessed(event.resource.id, event.resource);
break;
case "BILLING.SUBSCRIPTION.ACTIVATED":
await activateSubscription(event.resource.id, event.resource);
break;
case "BILLING.SUBSCRIPTION.CANCELLED":
await cancelSubscription(event.resource.id);
break;
case "CUSTOMER.DISPUTE.CREATED":
await createDisputeTicket(event.resource);
await notifyAdminDispute(event.resource);
break;
default:
console.log(`Unhandled event: ${event.event_type}`);
}
}
9. Subscription Integration
Create Subscription Plan
// app/api/paypal/create-plan/route.ts
export async function POST() {
const token = await getAccessToken();
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/v1/billing/plans`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_id: "PROD-XXXX",
name: "Pro Monthly",
description: "Pro tier subscription",
billing_cycles: [
{
frequency: { interval_unit: "MONTH", interval_count: 1 },
tenure_type: "REGULAR",
sequence: 1,
pricing_scheme: {
fixed_price: { value: "19.00", currency_code: "USD" },
},
},
],
payment_preferences: {
auto_bill_outstanding: true,
setup_fee: { value: "0", currency_code: "USD" },
setup_fee_failure_action: "CONTINUE",
},
}),
}
);
return NextResponse.json(await res.json());
}
10. Refund Endpoint
// app/api/paypal/refund/route.ts
export async function POST(req: NextRequest) {
const { captureId, amount } = await req.json();
const token = await getAccessToken();
const body = amount
? { amount: { value: amount, currency_code: "USD" } }
: {};
const res = await fetch(
`${process.env.PAYPAL_API_BASE}/v2/payments/captures/${captureId}/refund`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
const refund = await res.json();
return NextResponse.json(refund);
}
11. Studi Kasus — Indonesia SaaS Build PayPal Integration
Profil: Joko, founder Indonesia SaaS project management tool. Target: US + SG SMB. Pricing $29/month + $290/year.
Tech Stack
- Next.js 16 (App Router)
- PostgreSQL (Neon)
- Prisma ORM
- Vercel hosting
- PayPal REST API + Webhook
Implementation Time
- Day 1-2: OAuth2 + sandbox setup
- Day 3-4: Create order + capture (one-time)
- Day 5-7: Subscription + lifecycle webhook
- Day 8: Refund + cancellation
- Day 9: Webhook verify + idempotent
- Day 10: Testing (Cypress E2E)
- Day 11: Production go-live
- Day 12: Monitor + bug fix
Code Metrics
- ~1.500 LOC buat PayPal integration
- 8 API routes (create-order, capture, refund, webhook, create-plan, cancel-sub, billing-history, customer-portal)
- 3 React components (PayPalCheckout, PayPalSubscription, CustomerPortal)
- 50+ unit tests
- 12 E2E test scenarios
Result Month 1
- 142 customer subscribed
- 4% visit → paid conversion
- 0 production bug
- 1 dispute (won via webhook auto-respond)
- $3.800 MRR
Lesson: REST API integration = full control + custom UX. Worth invest waktu 10-12 hari.
12. Error Handling Best Practice
Retry Logic
async function callPayPalWithRetry(fn: () => Promise<Response>, maxRetry = 3) {
for (let i = 0; i < maxRetry; i++) {
try {
const res = await fn();
if (res.status === 429 || res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** i * 1000));
continue;
}
return res;
} catch (err) {
if (i === maxRetry - 1) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 1000));
}
}
throw new Error("PayPal API max retry exceeded");
}
Idempotency Key
const requestId = crypto.randomUUID();
const res = await fetch(url, {
method: "POST",
headers: {
"PayPal-Request-Id": requestId,
},
});
PayPal dedupe request dengan Request-Id sama dalam 24 jam.
13. Common Mistake Developer
Mistake 1: Client-Side Capture
Mistake: call capture API dari browser (with secret). Fix: capture SELALU server-side. Client cuma trigger button.
Mistake 2: Nggak Verify Webhook Signature
Mistake: trust webhook masuk. Fix: always verify. Forged webhook = hacker refund themselves.
Mistake 3: Trust Client Amount
Mistake: client set amount di create-order. Fix: server calculate dari DB, NEVER trust client input.
Mistake 4: Skip Idempotency
Mistake: retry request tanpa idempotency key.
Fix: always include PayPal-Request-Id.
Mistake 5: Log Secret
Mistake: console.log(request body including secret). Fix: redact sensitive fields. Pakai logger structured.
Mistake 6: Nggak Handle Network Failure
Mistake: assume PayPal always respond. Fix: timeout 30s + retry logic + fallback queue.
Mistake 7: Webhook URL HTTP
Mistake: webhook URL HTTP (PayPal reject). Fix: HTTPS wajib. Pakai ngrok untuk dev.
14. Testing PayPal API
Unit Test
// __tests__/paypal.test.ts
import { describe, it, expect, vi } from "vitest";
describe("PayPal create order", () => {
it("should create order with correct amount", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "ORDER-123" }),
});
global.fetch = mockFetch as any;
const result = await createOrder([
{ name: "Item 1", amount: 10, quantity: 2 },
]);
expect(result.id).toBe("ORDER-123");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/v2/checkout/orders"),
expect.objectContaining({ method: "POST" })
);
});
});
E2E Test
// cypress/e2e/paypal-subscription.cy.ts
describe("PayPal Subscription", () => {
it("completes subscription signup", () => {
cy.visit("/pricing");
cy.get("[data-cy=subscribe-pro]").click();
cy.origin("https://www.sandbox.paypal.com", () => {
cy.get("#email").type("test-buyer@example.com");
cy.get("#password").type("Test1234!");
cy.get("#btnLogin").click();
cy.get("#confirmButtonTop", { timeout: 15000 }).click();
});
cy.url().should("include", "/dashboard");
cy.contains("Pro Active").should("be.visible");
});
});
15. Production Checklist
Pre-Launch
- Sandbox test passing
- Live credentials set
- HTTPS enforced
- Webhook signature verify ON
- Idempotency key implemented
- Error monitoring (Sentry)
- Log webhook raw (90 hari retention)
- Reconciliation cron job
Security
- Secret NOT in client bundle
- CORS restricted to own domain
- Rate limiting ON (per IP)
- SQL injection prevention (parameterized)
- XSS prevention (escape user input)
- CSRF token for state-changing endpoints
Compliance
- Privacy policy
- Terms of service
- Refund policy
- Cookie consent (GDPR)
- PCI DSS SAQ-A scope
16. FAQ PayPal API Indonesia
Q: Bisanya Indonesia developer daftar PayPal API?
A: Bisa. PayPal developer account gratis. API access global.
Q: Apakah perlu LLC US buat PayPal API?
A: Nggak. Personal/Business account Indonesia cukup. LLC needed only buat high volume.
Q: Berapa rate limit PayPal API?
A: 4.000 request/menit per app. Lebih dari itu = 429 error.
Q: Bisanya webhook delay?
A: Kadang 1-30 detik. Selalu implement reconciliation polling.
Q: Best library PayPal Node.js?
A: @paypal/react-paypal-js (client) + raw fetch (server). Hindari paypal-rest-sdk (deprecated).
17. Mitos vs Fakta PayPal API
Mitos 1: "PayPal API Ribet"
Fakta: 4 endpoint dasar: OAuth, create-order, capture, webhook. Sisanya fitur tambahan.
Mitos 2: "SDK Mahal"
Fakta: SDK gratis. Cuma transaction fee (4.4% + $0.30).
Mitos 3: "Webhook Optional"
Fakta: Wajib buat reliable system. Capture return false positive kalau buyer cancel before redirect.
Mitos 4: "Live Test Sama dengan Sandbox"
Fakta: 95% sama. Fraud + 3DS beda di live.
Mitos 5: "Sekali Setup, Selesai Selamanya"
Fakta: PayPal update API version tiap tahun. Maintain SDK update.
18. Verdict — REST API = Full Control Integration
PayPal REST API = jalur professional buat Indonesia developer yang mau full control + custom UX + scale.
Yang paling critical:
- Server-side capture (NEVER client-side)
- Webhook verify signature
- Idempotency key
- OAuth2 token cache
- Error handling + retry
Yang perlu di-avoid:
- Client-side capture
- Trust client amount
- Skip webhook verify
- Hardcode secret
- HTTP webhook URL
Yang always do:
- Log raw webhook
- Reconciliation job
- E2E test Cypress
- Monitor conversion rate
- Update SDK rutin
ChatBot Cell siap bantu setup PayPal REST API integration + Next.js + webhook handler + production deploy. Plus AI Chatbot buat auto-handle webhook + alert anomaly + reconcile drift. Konsultasi gratis.







