Webhooks
Webhooks allow you to receive real-time notifications when events occur in Good Plan, such as when a plan is created, a payment is reported, or a plan is completed.
Overview
Good Plan sends HTTP POST requests to your webhook URL when subscribed events occur. This enables you to:
- Update your accounting system when payments are confirmed
- Trigger email notifications
- Sync data to spreadsheets or databases
- Build custom workflows
Setting Up Webhooks
- Log into Good Plan
- Navigate to Dashboard → Webhooks
- Click "Add Webhook"
- Enter your webhook URL (must be
https://in production) - Select the events you want to subscribe to
- Save - your webhook secret is automatically generated
Webhook Events
Plan Events
| Event | When It Fires |
|---|---|
plan.created |
New plan created (via UI or API) |
plan.activated |
Customer selects payment option |
plan.completed |
All payments finished |
plan.abandoned |
Plan marked as abandoned |
plan.cancelled |
Plan cancelled |
plan.written_off |
Plan written off |
plan.reactivated |
Plan reactivated |
Installment Events
| Event | When It Fires |
|---|---|
installment.paid |
Customer reports payment |
installment.confirmed |
Business confirms receipt |
installment.extension_granted |
Due date extended |
installment.deferred |
Payment moved to later date |
Proposal Events
| Event | When It Fires |
|---|---|
proposal.created |
Customer proposes alternative |
proposal.approved |
Business accepts proposal |
proposal.countered |
Business sends counter-offer |
proposal.declined |
Business rejects proposal |
proposal.counter_accepted |
Customer accepts counter |
Webhook Payload
All webhooks follow this structure:
{
"event": "plan.activated",
"timestamp": "2025-11-27T01:15:30Z",
"data": {
"plan": {
"id": 42,
"status": "active",
"total_installments": 6,
"payment_interval": "monthly",
"start_date": "2025-12-01",
"customer_portal_url": "https://..."
},
"invoice": {
"id": 890,
"invoice_number": "INV-2024-001",
"amount": "1200.00"
},
"customer": {
"id": 567,
"name": "John Doe",
"email": "john@example.com"
},
"business": {
"id": 1,
"name": "Your Business"
}
}
}
Security
Verifying Webhook Signatures
Always verify webhook signatures to ensure requests are from Good Plan.
Good Plan includes a signature header:
X-Webhook-Signature: sha256=abc123...
This is an HMAC-SHA256 hash of the raw request body using your webhook secret.
PHP Example
function verifyWebhookSignature($payload, $signature, $secret)
{
// Remove 'sha256=' prefix if present
$signature = str_replace('sha256=', '', $signature);
// Compute HMAC
$computed = hash_hmac('sha256', $payload, $secret);
// Use timing-safe comparison
return hash_equals($computed, $signature);
}
// Example webhook receiver
$rawPayload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'your_webhook_secret_from_goodplan';
// Verify signature BEFORE processing
if (!verifyWebhookSignature($rawPayload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
// Parse and handle the event
$event = json_decode($rawPayload, true);
switch ($event['event']) {
case 'plan.activated':
handlePlanActivated($event['data']);
break;
case 'installment.confirmed':
handleInstallmentConfirmed($event['data']);
break;
}
http_response_code(200);
echo json_encode(['received' => true]);
Python Example
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret'
def verify_signature(payload, signature, secret):
if signature.startswith('sha256='):
signature = signature[7:]
computed = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, signature)
@app.route('/webhooks/goodplan', methods=['POST'])
def handle_webhook():
payload = request.get_data()
signature = request.headers.get('X-Webhook-Signature', '')
if not verify_signature(payload, signature, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
if event['event'] == 'plan.activated':
handle_plan_activated(event['data'])
elif event['event'] == 'installment.confirmed':
handle_installment_confirmed(event['data'])
return jsonify({'received': True}), 200
Handling Events
Plan Activated
When a customer selects a payment option:
function handlePlanActivated($data)
{
$planId = $data['plan']['id'];
$installments = $data['plan']['total_installments'];
$invoiceNumber = $data['invoice']['invoice_number'];
// Update your system
updateInvoiceStatus($invoiceNumber, 'payment_plan_active');
// Send confirmation
mail(
$data['customer']['email'],
'Payment Plan Activated',
"Your {$installments}-payment plan is now active!"
);
}
Installment Confirmed
When a payment is verified by the business:
function handleInstallmentConfirmed($data)
{
$installmentNum = $data['installment']['installment_number'];
$amount = $data['installment']['amount'];
$invoiceNumber = $data['invoice']['invoice_number'];
// Record payment in accounting system
recordPayment(
$invoiceNumber,
$amount,
"Installment {$installmentNum}"
);
}
Plan Completed
When all payments are finished:
function handlePlanCompleted($data)
{
$invoiceNumber = $data['invoice']['invoice_number'];
// Mark invoice as paid in full
updateInvoiceStatus($invoiceNumber, 'paid_in_full');
// Send thank you email
sendThankYou($data['customer']['email']);
}
Best Practices
Return 200 Quickly
// ✅ Good: Queue for background processing
$event = json_decode(file_get_contents('php://input'), true);
queue_job('ProcessWebhook', $event);
http_response_code(200);
echo json_encode(['queued' => true]);
// ❌ Bad: Long processing blocks response
processEverything(); // Takes 30 seconds
updateDatabase(); // May timeout
Handle Idempotency
function processWebhook($event)
{
$eventId = $event['plan']['id'] . '-' . $event['timestamp'];
// Check if already processed
if (isEventProcessed($eventId)) {
return; // Skip duplicate
}
// Process event
handleEvent($event);
// Mark as processed
markEventProcessed($eventId);
}
Use HTTPS
- Good Plan requires
https://webhook URLs in production - Use Let's Encrypt for free SSL certificates
- Test with webhook.site during development
Monitor Failures
Good Plan automatically retries failed webhooks:
- 3 retry attempts with exponential backoff
- Webhooks auto-disable after 10 consecutive failures
- View delivery logs in the Dashboard → Webhooks page
Testing
Using webhook.site
- Go to webhook.site
- Copy your unique URL
- Add it as a webhook in Good Plan
- Click "Test" to send a test payload
- View the received payload on webhook.site
Test Events
When you click "Test" in the webhook UI, Good Plan sends:
{
"event": "webhook.test",
"timestamp": "2025-11-27T01:15:30Z",
"data": {
"message": "This is a test webhook from Good Plan",
"business": {
"id": 1,
"name": "Your Business Name"
}
}
}
Zapier Integration
Combine webhooks with the API for full two-way sync:
- API - Your system → Good Plan (create plans)
- Webhooks - Good Plan → Your system (receive updates)
Example Zap:
- Trigger: Webhooks by Zapier (Catch Hook)
- Filter: Only when
eventequalsinstallment.confirmed - Action: Update QuickBooks invoice
Troubleshooting
Webhooks not arriving?
- Check webhook is active in Dashboard → Webhooks
- Verify URL is accessible (test with curl)
- Check your server's firewall settings
- View delivery logs for error messages
Signature verification failing?
- Use the raw request body (before parsing)
- Get secret from Good Plan webhook settings
- Remove
sha256=prefix from signature header - Use constant-time comparison (e.g.,
hash_equalsin PHP)
High failure rate?
- Return 200 OK within 10 seconds
- Queue long-running tasks for background processing
- Handle network errors gracefully
- Log errors for debugging
Next Steps
- Generate your API key for two-way integration
- View webhook delivery logs in Dashboard → Webhooks
- Test with webhook.site