Webhooks
Receive real-time event notifications with secure webhook endpoints, signature verification, and retry handling
Webhooks
Webhooks allow your application to receive real-time notifications when events occur in your GetPaidHQ account. Instead of repeatedly polling the API for changes, webhooks push event data to your application as it happens.
How webhooks work
- Configure webhook endpoints in your GetPaidHQ dashboard or via API
- Subscribe to specific event types you want to receive
- GetPaidHQ sends HTTP POST requests to your endpoint when events occur
- Your application processes the event and returns a 200 status code
- GetPaidHQ retries failed deliveries with exponential backoff
Setting up webhooks
Create a webhook endpoint
First, create an endpoint in your application to receive webhook events:
// Express.js example
app.post('/webhooks/getpaidhq', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['getpaidhq-signature'];
const event = verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET);
// Process the event
handleWebhookEvent(event);
// Return 200 to acknowledge receipt
res.status(200).send('OK');
});
Register the webhook
Register your endpoint with GetPaidHQ:
curl https://api.getpaidhq.com/api/webhooks \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/getpaidhq",
"events": [
"customer.created",
"subscription.created",
"payment.succeeded",
"payment.failed"
],
"description": "Production webhook endpoint"
}'
Event structure
All webhook events follow a consistent structure:
{
"id": "evt_1234567890abcdef",
"type": "payment.succeeded",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"id": "pay_1234567890",
"amount": 2999,
"currency": "USD",
"customer_id": "cus_1234567890",
"subscription_id": "sub_1234567890",
"status": "succeeded",
"created_at": "2024-01-15T10:30:00Z"
},
"previous_data": {
"status": "pending"
}
}
Event properties
id
: Unique identifier for the eventtype
: The event type (see available events below)created_at
: When the event occurreddata
: The resource object related to the eventprevious_data
: Previous values for updated fields (update events only)
Available events
Customer events
Event | Description |
---|---|
customer.created | New customer created |
customer.updated | Customer information updated |
customer.deleted | Customer deleted |
Subscription events
Event | Description |
---|---|
subscription.created | New subscription created |
subscription.updated | Subscription modified |
subscription.canceled | Subscription canceled |
subscription.paused | Subscription paused |
subscription.resumed | Subscription resumed |
subscription.expired | Subscription expired |
Payment events
Event | Description |
---|---|
payment.succeeded | Payment completed successfully |
payment.failed | Payment failed |
payment.refunded | Payment refunded |
payment.partially_refunded | Payment partially refunded |
Invoice events
Event | Description |
---|---|
invoice.created | New invoice generated |
invoice.sent | Invoice sent to customer |
invoice.paid | Invoice payment received |
invoice.payment_failed | Invoice payment failed |
invoice.voided | Invoice voided |
Usage events
Event | Description |
---|---|
usage_record.created | Usage record created |
usage_record.processed | Usage record processed for billing |
Dunning events
Event | Description |
---|---|
dunning.campaign_started | Dunning campaign initiated |
dunning.reminder_sent | Payment reminder sent |
dunning.payment_recovered | Failed payment recovered |
dunning.subscription_canceled | Subscription canceled due to failed payments |
Webhook verification
Signature verification
GetPaidHQ signs webhook payloads with your webhook secret. Always verify signatures to ensure authenticity:
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const providedSignature = signature.replace('sha256=', '');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(providedSignature, 'hex')
)) {
throw new Error('Invalid webhook signature');
}
return JSON.parse(payload);
}
Timestamp verification
Check the webhook timestamp to prevent replay attacks:
function verifyTimestamp(timestamp, tolerance = 300) {
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
if (Math.abs(now - webhookTime) > tolerance) {
throw new Error('Webhook timestamp too old');
}
}
// Usage
app.post('/webhooks/getpaidhq', (req, res) => {
const timestamp = req.headers['getpaidhq-timestamp'];
const signature = req.headers['getpaidhq-signature'];
verifyTimestamp(timestamp);
const event = verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET);
handleWebhookEvent(event);
res.status(200).send('OK');
});
Processing events
Idempotent processing
Always process events idempotently to handle duplicate deliveries:
const processedEvents = new Set();
function handleWebhookEvent(event) {
// Check if already processed
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed`);
return;
}
try {
switch (event.type) {
case 'payment.succeeded':
handlePaymentSucceeded(event.data);
break;
case 'payment.failed':
handlePaymentFailed(event.data);
break;
case 'subscription.canceled':
handleSubscriptionCanceled(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark as processed
processedEvents.add(event.id);
} catch (error) {
console.error(`Error processing event ${event.id}:`, error);
throw error; // Trigger retry
}
}
Database-backed idempotency
For production applications, use database storage:
async function handleWebhookEvent(event) {
// Check if already processed
const existingEvent = await db.webhookEvents.findUnique({
where: { eventId: event.id }
});
if (existingEvent) {
console.log(`Event ${event.id} already processed`);
return;
}
// Start transaction
await db.$transaction(async (tx) => {
// Record the event
await tx.webhookEvents.create({
data: {
eventId: event.id,
eventType: event.type,
processedAt: new Date()
}
});
// Process the event
await processEvent(event, tx);
});
}
Event handling examples
Payment succeeded
async function handlePaymentSucceeded(payment) {
console.log(`Payment ${payment.id} succeeded for $${payment.amount/100}`);
// Update customer's account
await updateCustomerAccount(payment.customer_id, {
lastPaymentAt: payment.created_at,
totalPaid: payment.amount
});
// Send confirmation email
await sendPaymentConfirmation(payment);
// Update analytics
await recordPaymentMetrics(payment);
}
Payment failed
async function handlePaymentFailed(payment) {
console.log(`Payment ${payment.id} failed: ${payment.failure_reason}`);
// Check if subscription should be suspended
const subscription = await getpaidhq.subscriptions.retrieve(payment.subscription_id);
const failedPaymentCount = await getFailedPaymentCount(subscription.id);
if (failedPaymentCount >= 3) {
// Suspend subscription after 3 failed payments
await getpaidhq.subscriptions.update(subscription.id, {
status: 'past_due'
});
// Notify customer
await sendPaymentFailureNotification(subscription.customer_id, {
paymentId: payment.id,
reason: payment.failure_reason,
retryDate: payment.next_retry_at
});
}
}
Subscription canceled
async function handleSubscriptionCanceled(subscription) {
console.log(`Subscription ${subscription.id} canceled`);
// Revoke access
await revokeCustomerAccess(subscription.customer_id);
// Send cancellation confirmation
await sendCancellationConfirmation(subscription);
// Update customer status
await updateCustomerStatus(subscription.customer_id, 'inactive');
// Trigger retention campaign
await triggerRetentionCampaign(subscription.customer_id);
}
Retry behavior
GetPaidHQ automatically retries failed webhook deliveries:
Retry schedule
- Immediate: First retry within 1 minute
- Exponential backoff: 1min, 5min, 30min, 2hr, 12hr, 24hr
- Maximum attempts: 7 attempts over 3 days
- Final attempt: Manual retry available in dashboard
Failure conditions
Webhooks are considered failed if:
- HTTP response code is not 2xx
- Request times out (30 seconds)
- Connection cannot be established
- SSL certificate is invalid
Retry headers
Retry attempts include additional headers:
GetPaidHQ-Delivery-Attempt: 3
GetPaidHQ-Delivery-ID: del_1234567890abcdef
Best practices
Endpoint security
// ✅ Secure webhook endpoint
app.post('/webhooks/getpaidhq',
express.raw({type: 'application/json'}),
verifyWebhookSignature,
handleWebhook
);
function verifyWebhookSignature(req, res, next) {
try {
const signature = req.headers['getpaidhq-signature'];
const timestamp = req.headers['getpaidhq-timestamp'];
verifyTimestamp(timestamp);
verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET);
next();
} catch (error) {
res.status(400).send('Invalid signature');
}
}
Error handling
async function handleWebhook(req, res) {
try {
const event = JSON.parse(req.body);
await handleWebhookEvent(event);
res.status(200).send('OK');
} catch (error) {
console.error('Webhook processing failed:', error);
res.status(500).send('Processing failed');
}
}
Logging and monitoring
function handleWebhookEvent(event) {
console.log(`Processing webhook: ${event.type} (${event.id})`);
const startTime = Date.now();
try {
processEvent(event);
const duration = Date.now() - startTime;
console.log(`Webhook processed successfully in ${duration}ms`);
// Record metrics
metrics.increment('webhook.processed', {
event_type: event.type,
status: 'success'
});
} catch (error) {
console.error(`Webhook processing failed:`, error);
metrics.increment('webhook.processed', {
event_type: event.type,
status: 'error'
});
throw error;
}
}
Testing webhooks
Local development
Use tools like ngrok to expose your local server:
# Install ngrok
npm install -g ngrok
# Expose local port
ngrok http 3000
# Use the ngrok URL in your webhook configuration
https://abc123.ngrok.io/webhooks/getpaidhq
Test events
Send test events from the dashboard or API:
curl https://api.getpaidhq.com/api/webhooks/webhook_123/test \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"event_type": "payment.succeeded"
}'
Webhook simulation
Create a test endpoint to simulate webhook events:
// Test webhook handler
app.post('/test/webhook', (req, res) => {
const testEvent = {
id: 'evt_test_' + Date.now(),
type: 'payment.succeeded',
created_at: new Date().toISOString(),
data: {
id: 'pay_test_123',
amount: 2999,
currency: 'USD',
customer_id: 'cus_test_123',
status: 'succeeded'
}
};
handleWebhookEvent(testEvent);
res.json({ success: true });
});
Webhook management
List webhooks
curl https://api.getpaidhq.com/api/webhooks \
-H "Authorization: Bearer pk_live_..."
Update webhook
curl https://api.getpaidhq.com/api/webhooks/webhook_123 \
-X PUT \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"events": ["payment.succeeded", "payment.failed"],
"enabled": true
}'
Delete webhook
curl https://api.getpaidhq.com/api/webhooks/webhook_123 \
-X DELETE \
-H "Authorization: Bearer pk_live_..."
Troubleshooting
Common issues
Webhook not receiving events:
- Check webhook URL is accessible from the internet
- Verify SSL certificate is valid
- Ensure endpoint returns 200 status code
- Check webhook is enabled and subscribed to correct events
Signature verification failing:
- Verify webhook secret is correct
- Check timestamp tolerance (default 5 minutes)
- Ensure raw request body is used for signature verification
Duplicate events:
- Implement idempotent processing using event IDs
- Store processed event IDs to prevent reprocessing
Debugging tools
View webhook delivery logs in the GetPaidHQ dashboard:
- Delivery attempts and responses
- Payload and headers sent
- Retry schedule and status
- Manual retry options
Support
For webhook-related issues:
- Check the webhook delivery logs in your dashboard
- Verify your endpoint is accessible and returning 200 responses
- Review the event structure documentation
- Contact support with specific webhook IDs for detailed investigation