LedgerCore
Webhooks

Payment Webhooks

LedgerCore accepts two payment provider webhooks in addition to the Clerk-driven subscription flow: PayPal and PayFast. Both feed the same WebhookCreditsHandler so that a successful payment grants credits instantly.

PayPal

Endpoint

POST /api/v1/billing/paypal/webhook

Authentication

PayPal signs every webhook. The gateway verifies the signature using PayPal's SDK and the webhook id from your PayPal dashboard.

Required env vars:

VariableNotes
PAYPAL_CLIENT_IDApp client id (sandbox or live).
PAYPAL_CLIENT_SECRETApp client secret.
PAYPAL_MODEsandbox or live.
PAYPAL_WEBHOOK_IDWebhook id from the PayPal dashboard — required for signature verification.

Handled event types

EventEffect
BILLING.SUBSCRIPTION.ACTIVATEDCreate or reactivate a LedgerCore subscription + initial credit grant.
BILLING.SUBSCRIPTION.UPDATEDPlan changes propagate to billing_subscriptions + top-up credits.
BILLING.SUBSCRIPTION.CANCELLEDMark subscription as cancelled at period end; no more renewals.
BILLING.SUBSCRIPTION.PAYMENT.FAILEDFlag the subscription; next request with insufficient credits returns 402.
PAYMENT.SALE.COMPLETEDOne-off top-ups grant credits immediately.

Example payload

Shortened — the real PayPal envelope is much larger.

{
  "id": "WH-58D329510W468432D-8HN650336L201105X",
  "event_type": "BILLING.SUBSCRIPTION.ACTIVATED",
  "resource": {
    "id": "I-BW452GLLEP1G",
    "status": "ACTIVE",
    "plan_id": "P-5ML4271244454362WXNWU5NQ",
    "custom_id": "user_32T5kyEywX9x8X3P3XGxcyptIbn",
    "billing_info": { "next_billing_time": "2026-05-18T10:00:00Z" }
  }
}
custom_id is sacred

Always set PayPal's custom_id on the subscription to the Clerk user id. It's the only way LedgerCore can attribute the payment back to a user — subscriptions without a custom_id will 400.


PayFast

PayFast is the primary South African payment rail — used for ZAR credit purchases and ZARP stablecoin flows.

Endpoint

POST /api/v1/billing/payfast/webhook

Authentication

PayFast sends form-encoded parameters plus a signature field that's an MD5 hash of the remaining params in the order they were sent, optionally with the merchant passphrase appended. The gateway verifies this signature using PAYFAST_MERCHANT_KEY and PAYFAST_MERCHANT_PASSPHRASE.

Expected params

m_payment_id=order_2x...
pf_payment_id=1234567
payment_status=COMPLETE
amount_gross=499.00
merchant_id=10000100
merchant_key=...
custom_str1=user_32T5kyEywX9x8X3P3XGxcyptIbn
signature=ab12cd34ef...

Processing

payment_statusEffect
COMPLETEGrant credits matching the package tied to m_payment_id.
FAILEDLog failure; no state change.
CANCELLEDLog cancellation; no state change.

Example

curl -X POST http://localhost:8090/api/v1/billing/payfast/webhook \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "m_payment_id=order_123&pf_payment_id=987654&payment_status=COMPLETE&amount_gross=499.00&merchant_id=10000100&merchant_key=xxxx&custom_str1=user_32T5...&signature=abc..."

Shared behaviour

Both providers ultimately call WebhookCreditsHandler.grantCreditsFromPayment(), which:

  1. Resolves the Clerk user id to an internal UUID via resolveInternalUserId.
  2. Looks up the credit_packages row that matches the payment amount / plan id.
  3. Calls grant_user_credits — same stored function as the REST POST /api/v1/credits/grant.
  4. Emits credits_granted + credit_balance_updated over the WebSocket.
  5. Returns 200 (PayPal) or the PayFast-specific ACK body.

Failed or duplicate payments short-circuit with 200 so the provider stops retrying; the event is still recorded in credit_transactions.metadata for audit.

Was this page helpful?