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:
| Variable | Notes |
|---|---|
PAYPAL_CLIENT_ID | App client id (sandbox or live). |
PAYPAL_CLIENT_SECRET | App client secret. |
PAYPAL_MODE | sandbox or live. |
PAYPAL_WEBHOOK_ID | Webhook id from the PayPal dashboard — required for signature verification. |
Handled event types
| Event | Effect |
|---|---|
BILLING.SUBSCRIPTION.ACTIVATED | Create or reactivate a LedgerCore subscription + initial credit grant. |
BILLING.SUBSCRIPTION.UPDATED | Plan changes propagate to billing_subscriptions + top-up credits. |
BILLING.SUBSCRIPTION.CANCELLED | Mark subscription as cancelled at period end; no more renewals. |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | Flag the subscription; next request with insufficient credits returns 402. |
PAYMENT.SALE.COMPLETED | One-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" }
}
}
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_status | Effect |
|---|---|
COMPLETE | Grant credits matching the package tied to m_payment_id. |
FAILED | Log failure; no state change. |
CANCELLED | Log 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:
- Resolves the Clerk user id to an internal UUID via
resolveInternalUserId. - Looks up the
credit_packagesrow that matches the payment amount / plan id. - Calls
grant_user_credits— same stored function as the RESTPOST /api/v1/credits/grant. - Emits
credits_granted+credit_balance_updatedover the WebSocket. - 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.