Skip to main content

Overview

Webhooks allow you to receive real-time notifications when events occur in your PayNow store. This guide covers webhook setup, payload types, and best practices for handling webhook events.

Setting Up Webhooks

Create a Webhook Subscription

Use the Management API to create a webhook subscription:
const webhook = await client.POST('/v1/stores/{storeId}/webhooks', {
  params: {
    path: { storeId: 'YOUR_STORE_ID' }
  },
  body: {
    url: 'https://yourserver.com/webhooks/paynow',
    events: [
      'ON_ORDER_COMPLETED',
      'ON_SUBSCRIPTION_RENEWED',
      'ON_DELIVERY_ITEM_ADDED'
    ],
    secret: 'your-webhook-secret' // For signature verification
  }
});

List Webhook Subscriptions

const webhooks = await client.GET('/v1/stores/{storeId}/webhooks', {
  params: {
    path: { storeId: 'YOUR_STORE_ID' }
  }
});

Available Webhook Events

PayNow supports the following webhook event types:

Order Events

  • ON_ORDER_COMPLETED - Triggered when an order is completed

Subscription Events

  • ON_SUBSCRIPTION_ACTIVATED - Subscription becomes active
  • ON_SUBSCRIPTION_RENEWED - Subscription is renewed
  • ON_SUBSCRIPTION_CANCELED - Subscription is canceled

Delivery Events

  • ON_DELIVERY_ITEM_ADDED - Item added to customer inventory
  • ON_DELIVERY_ITEM_ACTIVATED - Item becomes active
  • ON_DELIVERY_ITEM_USED - Item is marked as used (deprecated)
  • ON_DELIVERY_ITEM_RENEWED - Item is renewed
  • ON_DELIVERY_ITEM_EXPIRED - Item expires
  • ON_DELIVERY_ITEM_REVOKED - Item is revoked

Payment Events

  • ON_REFUND - Refund is processed
  • ON_CHARGEBACK - Chargeback is created
  • ON_CHARGEBACK_CLOSED - Chargeback is resolved

Trial Events

  • ON_TRIAL_ACTIVATED - Trial period starts
  • ON_TRIAL_COMPLETED - Trial period ends
  • ON_TRIAL_CANCELED - Trial is canceled

Discord Events

  • ON_DISCORD_ACCOUNT_LINKED_TO_CHECKOUT - Discord account linked

Webhook Payload Structure

All webhook payloads follow a consistent structure:
interface WebhookPayload<T> {
  event_type: string;  // e.g., "ON_ORDER_COMPLETED"
  event_id: string;    // Unique event identifier
  body: T;             // Event-specific data
}

Using Webhook Payload Types

The SDK provides full TypeScript types for all webhook payloads through the webhooks interface:
import type { webhooks } from './generated/webhooks';

// Get the payload type for a specific webhook
type OrderCompletedPayload = webhooks['ON_ORDER_COMPLETED']['post']['requestBody']['content']['application/json'];

type SubscriptionRenewedPayload = webhooks['ON_SUBSCRIPTION_RENEWED']['post']['requestBody']['content']['application/json'];

type DeliveryItemAddedPayload = webhooks['ON_DELIVERY_ITEM_ADDED']['post']['requestBody']['content']['application/json'];

Building a Webhook Handler

Basic Express.js Handler

import express from 'express';
import type { webhooks } from './generated/webhooks';

const app = express();

app.post('/webhooks/paynow', express.json(), async (req, res) => {
  const payload = req.body;
  const eventType = payload.event_type;

  try {
    switch (eventType) {
      case 'ON_ORDER_COMPLETED':
        await handleOrderCompleted(payload);
        break;
      case 'ON_SUBSCRIPTION_RENEWED':
        await handleSubscriptionRenewed(payload);
        break;
      case 'ON_DELIVERY_ITEM_ADDED':
        await handleDeliveryItemAdded(payload);
        break;
      default:
        console.log('Unhandled event type:', eventType);
    }

    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).send('Error processing webhook');
  }
});

Type-Safe Event Handlers

import type { webhooks } from './generated/webhooks';

type OrderCompletedPayload = webhooks['ON_ORDER_COMPLETED']['post']['requestBody']['content']['application/json'];

async function handleOrderCompleted(payload: OrderCompletedPayload) {
  const { event_id, body } = payload;
  
  console.log('Order ID:', body.id);
  console.log('Customer:', body.customer.name);
  console.log('Total:', body.total_amount);
  console.log('Currency:', body.currency);
  
  // Process order lines
  body.lines.forEach(line => {
    console.log('Product:', line.product_name);
    console.log('Quantity:', line.quantity);
    console.log('Price:', line.price);
  });
  
  // Store event_id to prevent duplicate processing
  await markEventProcessed(event_id);
}

Chargeback Handler

import type { webhooks } from './generated/webhooks';

type ChargebackPayload = webhooks['ON_CHARGEBACK']['post']['requestBody']['content']['application/json'];

async function handleChargeback(payload: ChargebackPayload) {
  const { body } = payload;
  
  console.log('Payment ID:', body.id);
  console.log('Order ID:', body.order_id);
  console.log('Amount:', body.amount);
  console.log('Chargeback Status:', body.chargeback_status);
  
  // Revoke access or items
  await revokeOrderItems(body.order_id);
  
  // Flag customer account
  if (body.customer) {
    await flagCustomerAccount(body.customer.id, 'chargeback');
  }
}

Advanced Webhook Handling

Webhook Signature Verification

Always verify webhook signatures to ensure requests are from PayNow.
import crypto from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  );
}

app.post('/webhooks/paynow', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-paynow-signature'] as string;
  const payload = req.body.toString('utf8');
  
  if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  
  const data = JSON.parse(payload);
  // Process webhook...
});

Idempotent Processing

Prevent duplicate processing using event IDs:
const processedEvents = new Set<string>();

async function processWebhook(payload: any) {
  const eventId = payload.event_id;
  
  // Check if already processed
  if (processedEvents.has(eventId)) {
    console.log('Event already processed:', eventId);
    return;
  }
  
  // Or check database
  const exists = await db.webhookEvents.findOne({ event_id: eventId });
  if (exists) {
    return;
  }
  
  // Process event
  await handleEvent(payload);
  
  // Mark as processed
  processedEvents.add(eventId);
  await db.webhookEvents.create({ event_id: eventId, processed_at: new Date() });
}

Retry Logic

1

Implement Exponential Backoff

async function processWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      const delay = Math.pow(2, i) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw new Error('Max retries exceeded');
}
2

Queue Failed Webhooks

async function handleWebhook(payload: any) {
  try {
    await processWithRetry(() => processWebhook(payload));
  } catch (error) {
    // Queue for manual processing
    await queue.add('failed-webhook', { payload, error: error.message });
  }
}

Webhook Testing

// Test webhook handler locally
import type { webhooks } from './generated/webhooks';

const mockOrderPayload: webhooks['ON_ORDER_COMPLETED']['post']['requestBody']['content']['application/json'] = {
  event_type: 'ON_ORDER_COMPLETED',
  event_id: 'test-event-123',
  body: {
    id: 'order-123',
    store_id: 'store-456',
    customer_id: 'customer-789',
    customer: {
      id: 'customer-789',
      name: 'Test User',
      steam_id: '76561198152492642'
    },
    total_amount: 1999,
    currency: 'USD',
    lines: [
      {
        product_id: 'product-1',
        product_name: 'Test Product',
        quantity: 1,
        price: 1999
      }
    ],
    status: 'completed',
    created_at: new Date().toISOString()
  }
};

// Test the handler
await handleOrderCompleted(mockOrderPayload);

Common Use Cases

Grant Game Access on Purchase

async function handleDeliveryItemAdded(payload: DeliveryItemAddedPayload) {
  const { body } = payload;
  
  if (body.customer && body.product) {
    // Grant game permissions
    await gameServer.grantPermissions({
      steamId: body.customer.steam_id,
      productId: body.product_id,
      productName: body.product.name
    });
    
    // Send in-game notification
    await gameServer.sendMessage(
      body.customer.steam_id,
      `You've received: ${body.product.name}`
    );
  }
}

Discord Integration

import type { webhooks } from './generated/webhooks';

type DiscordLinkedPayload = webhooks['ON_DISCORD_ACCOUNT_LINKED_TO_CHECKOUT']['post']['requestBody']['content']['application/json'];

async function handleDiscordLinked(payload: DiscordLinkedPayload) {
  const { body } = payload;
  
  // Add roles to Discord user
  await discord.addMemberRole(
    body.discord_user_id,
    'customer-role-id'
  );
  
  // Send welcome message
  await discord.sendDM(body.discord_user_id, {
    embeds: [{
      title: 'Thanks for your purchase!',
      description: `You've purchased ${body.product.name}`,
      color: 0x00ff00
    }]
  });
}

Analytics Tracking

async function handleOrderCompleted(payload: OrderCompletedPayload) {
  const { body } = payload;
  
  // Track in analytics
  await analytics.track({
    event: 'Purchase Completed',
    userId: body.customer_id,
    properties: {
      orderId: body.id,
      revenue: body.total_amount / 100,
      currency: body.currency,
      products: body.lines.map(l => l.product_name)
    }
  });
}

Best Practices

  1. Response Quickly: Return 200 status as soon as possible
  2. Process Asynchronously: Queue webhooks for background processing
  3. Store Event IDs: Prevent duplicate processing
  4. Verify Signatures: Always validate webhook authenticity
  5. Handle Errors Gracefully: Implement retry logic for failures
  6. Log Everything: Keep detailed logs for debugging
  7. Test Thoroughly: Test with mock payloads before going live
  8. Monitor: Set up alerts for failed webhooks
PayNow will retry failed webhook deliveries with exponential backoff up to 3 times.

Debugging Webhooks

Enable Detailed Logging

app.post('/webhooks/paynow', express.json(), async (req, res) => {
  const payload = req.body;
  
  // Log everything in development
  if (process.env.NODE_ENV === 'development') {
    console.log('Webhook received:', JSON.stringify(payload, null, 2));
    console.log('Headers:', req.headers);
  }
  
  // Log to monitoring service
  logger.info('webhook_received', {
    event_type: payload.event_type,
    event_id: payload.event_id,
    timestamp: new Date().toISOString()
  });
  
  // Process...
});

Use Webhook Testing Tools

  • RequestBin: Inspect webhook payloads
  • ngrok: Test webhooks locally
  • Webhook.site: Debug webhook requests

Complete Webhook Server Example

import express from 'express';
import type { webhooks } from './generated/webhooks';

const app = express();
const processedEvents = new Set<string>();

app.post('/webhooks/paynow',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // Verify signature
    const signature = req.headers['x-paynow-signature'] as string;
    const payload = req.body.toString('utf8');
    
    if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }
    
    const data = JSON.parse(payload);
    const { event_type, event_id } = data;
    
    // Check for duplicates
    if (processedEvents.has(event_id)) {
      return res.status(200).send('Already processed');
    }
    
    // Respond quickly
    res.status(200).send('OK');
    
    // Process asynchronously
    setImmediate(async () => {
      try {
        switch (event_type) {
          case 'ON_ORDER_COMPLETED':
            await handleOrderCompleted(data);
            break;
          case 'ON_SUBSCRIPTION_RENEWED':
            await handleSubscriptionRenewed(data);
            break;
          case 'ON_DELIVERY_ITEM_ADDED':
            await handleDeliveryItemAdded(data);
            break;
          case 'ON_CHARGEBACK':
            await handleChargeback(data);
            break;
        }
        
        processedEvents.add(event_id);
      } catch (error) {
        logger.error('Webhook processing failed', { event_id, error });
        await queue.add('failed-webhook', { data, error: error.message });
      }
    });
  }
);

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Next Steps

Storefront Operations

Build customer-facing shopping experiences

Management Operations

Manage products, orders, and customers

Build docs developers (and LLMs) love