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

  1. Configure webhook endpoints in your GetPaidHQ dashboard or via API
  2. Subscribe to specific event types you want to receive
  3. GetPaidHQ sends HTTP POST requests to your endpoint when events occur
  4. Your application processes the event and returns a 200 status code
  5. 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 event
  • type: The event type (see available events below)
  • created_at: When the event occurred
  • data: The resource object related to the event
  • previous_data: Previous values for updated fields (update events only)

Available events

Customer events

EventDescription
customer.createdNew customer created
customer.updatedCustomer information updated
customer.deletedCustomer deleted

Subscription events

EventDescription
subscription.createdNew subscription created
subscription.updatedSubscription modified
subscription.canceledSubscription canceled
subscription.pausedSubscription paused
subscription.resumedSubscription resumed
subscription.expiredSubscription expired

Payment events

EventDescription
payment.succeededPayment completed successfully
payment.failedPayment failed
payment.refundedPayment refunded
payment.partially_refundedPayment partially refunded

Invoice events

EventDescription
invoice.createdNew invoice generated
invoice.sentInvoice sent to customer
invoice.paidInvoice payment received
invoice.payment_failedInvoice payment failed
invoice.voidedInvoice voided

Usage events

EventDescription
usage_record.createdUsage record created
usage_record.processedUsage record processed for billing

Dunning events

EventDescription
dunning.campaign_startedDunning campaign initiated
dunning.reminder_sentPayment reminder sent
dunning.payment_recoveredFailed payment recovered
dunning.subscription_canceledSubscription 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