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
Order Completed Handler
Subscription Renewed Handler
Delivery Item Added Handler
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
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' );
}
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
Response Quickly : Return 200 status as soon as possible
Process Asynchronously : Queue webhooks for background processing
Store Event IDs : Prevent duplicate processing
Verify Signatures : Always validate webhook authenticity
Handle Errors Gracefully : Implement retry logic for failures
Log Everything : Keep detailed logs for debugging
Test Thoroughly : Test with mock payloads before going live
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...
});
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