Skip to content

Services via PayPal

For uncle-z products that sell services (productized human work) — not digital products. Polar’s AUP rejects services; PayPal accepts them.

  • You’re selling time / human work: a dev sprint, a design review, a consulting hour. Examples in scope: book.uncle-z.com.
  • Buyer pays in USD/EUR/GBP — not IDR. (For IDR services, use QRIS via NICEPAY.)

If you’re a SaaS / digital product / one-time license, you want Polar instead.

Polar’s Acceptable Use Policy explicitly rejects services. So does Paddle’s. Both are MoR (Merchant of Record) and aggressive about category compliance. Putting service revenue through them risks account freeze + 90-180 day fund hold + AUP flags propagating to other MoR vendors.

PayPal accepts services without category objection. Trade-off: PayPal is NOT a MoR, so:

  • VAT / sales tax is the seller’s responsibility (you, not PayPal).
  • Disputes go through PayPal’s resolution flow, not a MoR safety net.
  • Currency conversion charges are visible to the buyer (PayPal shows the FX cost).

For book.uncle-z.com’s volume + a single-jurisdiction seller, those trade-offs are fine.

Step 1: PayPal setup (operator-side, one-time)

Section titled “Step 1: PayPal setup (operator-side, one-time)”

The operator does this once at gateway provisioning. You don’t have to repeat it per product. Listed for context:

  1. PayPal merchant account exists for uncle-z.
  2. App registered at developer.paypal.com with the Orders API enabled.
  3. Webhook subscription created at developer.paypal.com pointing to https://payment.uncle-z.com/webhooks/paypal. Events: CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.REFUNDED.
  4. Webhook ID copied into gateway env (PAYPAL_*_WEBHOOK_ID). The gateway needs this to call PayPal’s verify-webhook-signature API on each inbound delivery.
POST /v1/payments
X-PAY-Key: pk_<your app's public key>
X-PAY-Timestamp: <unix seconds>
X-PAY-Signature: <hex hmac>
Content-Type: application/json
{
"external_user_id": "<your user id>",
"email": "buyer@example.com",
"amount_minor": 5000,
"currency": "USD",
"method": "paypal",
"return_url": "https://<your-domain>/booking/return?id=...",
"invoice_prefix": "BOOK",
"metadata": {
"service_slug": "lite-engagement",
"scope_id": "<your scope id>"
}
}

method=paypal is a method override — wins over currency-based routing. You can use it for any non-IDR currency PayPal supports.

Response:

{
"payment_id": "abc123…",
"status": "initiated",
"checkout_url": "https://www.sandbox.paypal.com/checkoutnow?token=...",
"invoice_number": "BOOK-000042"
}

Redirect the buyer to checkout_url. PayPal handles the buyer-facing card / wallet UI, including authorization + 3DS.

PayPal’s signing scheme is async — the gateway POSTs the headers + body to PayPal’s /v1/notifications/verify-webhook-signature endpoint and reads verification_status: "SUCCESS" before accepting. This requires the gateway-side PAYPAL_*_WEBHOOK_ID env to match the webhook subscription’s ID. If it doesn’t, every inbound event 401s.

Your product side just receives the canonical payment.succeeded / payment.failed from the gateway via the standard outbound webhook. Same scheme as every other rail — see Verifying webhooks.

POST /v1/refunds works for PayPal. PayPal supports partial refunds; the gateway forwards amount_minor to PayPal’s capture-refund API. Refund webhook fires refund.succeeded once PayPal confirms.

PayPal-specific quirks:

  • Refunds before settlement (within 24h of capture) are free.
  • Refunds after settlement carry PayPal’s standard transaction fee on both legs.
  • Refund window: ~180 days from capture.
  • PayPal Subscriptions API — the gateway uses the Orders API (one-time charges) only. Subscriptions are a separate PayPal API surface; if you need them, talk to ops.
  • PayPal advanced credit card processing (embedded card form on your site) — outside the gateway’s scope. The gateway uses PayPal’s hosted checkout only.