Skip to main content

Testing Webhooks

Complete guide to testing webhook delivery, implementing webhook handlers, debugging webhook events, and setting up automated webhook testing for your Omise integration.

Overviewโ€‹

Webhooks are HTTP callbacks that notify your application about payment events in real-time. Testing webhooks properly ensures your application handles payment events correctly, processes duplicate events safely, and recovers gracefully from failures.

Why Test Webhooks?โ€‹

  • Event Handling: Ensure all payment events are processed correctly
  • Idempotency: Handle duplicate webhook deliveries safely
  • Security: Verify webhook signatures and prevent spoofing
  • Reliability: Handle delivery failures and network issues
  • Performance: Process webhooks asynchronously without timeouts
  • Debugging: Quickly identify and fix integration issues

Webhook Testing Strategiesโ€‹

  1. Local Testing: Use ngrok to test webhooks on your local machine
  2. Mock Testing: Use webhook testing services for quick testing
  3. Automated Testing: Write tests for your webhook handlers
  4. Integration Testing: Test end-to-end webhook flows
  5. Production Monitoring: Monitor webhook delivery and processing

Setting Up Webhook Testingโ€‹

Configuring Webhook Endpointsโ€‹

First, configure your webhook endpoint in the Omise Dashboard:

  1. Go to Settings > Webhooks
  2. Click Add Webhook Endpoint
  3. Enter your webhook URL
  4. Select events to receive
  5. Save your configuration

For local development, use ngrok to create a public URL.

Understanding Webhook Eventsโ€‹

Omise sends webhooks for these events:

EventDescription
charge.createCharge created
charge.completeCharge completed successfully
charge.expireCharge expired without payment
refund.createRefund created
transfer.createTransfer created
transfer.payTransfer paid out
customer.createCustomer created
customer.updateCustomer updated
customer.destroyCustomer deleted
card.createCard created
card.updateCard updated
card.destroyCard deleted
dispute.createDispute created
dispute.updateDispute updated

Testing with ngrokโ€‹

Installing ngrokโ€‹

ngrok creates a secure tunnel to your localhost, allowing Omise to send webhooks to your development machine.

Installation:

# macOS (Homebrew)
brew install ngrok

# Windows (Chocolatey)
choco install ngrok

# Linux (direct download)
wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip
unzip ngrok-stable-linux-amd64.zip
sudo mv ngrok /usr/local/bin/

# Verify installation
ngrok version

Sign up and authenticate:

# Sign up at https://dashboard.ngrok.com/signup
# Get your auth token from https://dashboard.ngrok.com/get-started/your-authtoken

ngrok config add-authtoken YOUR_AUTH_TOKEN

Using ngrok for Webhook Testingโ€‹

Start ngrok tunnel:

# Forward to local port 3000
ngrok http 3000

# With custom subdomain (paid plan)
ngrok http -subdomain=myapp 3000

# With custom region
ngrok http -region=ap 3000

ngrok output:

Session Status                online
Account your@email.com
Version 3.0.0
Region Asia Pacific (ap)
Web Interface http://127.0.0.1:4040
Forwarding https://abc123.ap.ngrok.io -> http://localhost:3000

Use the forwarding URL in Omise Dashboard:

Webhook URL: https://abc123.ap.ngrok.io/webhooks

Monitoring Webhook Requests with ngrokโ€‹

ngrok provides a web interface at http://127.0.0.1:4040 to inspect all requests:

  • View request headers and body
  • Inspect response status and body
  • Replay requests for debugging
  • View request/response timing
// Node.js - Basic webhook handler for ngrok testing
const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json());

app.post('/webhooks', (req, res) => {
const payload = JSON.stringify(req.body);
const signature = req.headers['x-omise-signature'];

console.log('Webhook received:', req.body.key);
console.log('Signature:', signature);
console.log('Payload:', payload);

// Verify signature
const secretKey = process.env.OMISE_SECRET_KEY;
const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(payload)
.digest('hex');

if (signature !== expectedSignature) {
console.log('Invalid signature!');
return res.status(401).json({ error: 'Invalid signature' });
}

console.log('Signature verified โœ“');

// Process webhook
processWebhook(req.body);

// Always respond 200 quickly
res.status(200).json({ received: true });
});

function processWebhook(event) {
// Process asynchronously
console.log(`Processing ${event.key}...`);
// Add to queue or process immediately
}

app.listen(3000, () => {
console.log('Webhook server running on port 3000');
console.log('Start ngrok: ngrok http 3000');
});
# Python Flask - Webhook handler for ngrok testing
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os

app = Flask(__name__)

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
# Get payload and signature
payload = request.get_data(as_text=True)
signature = request.headers.get('X-Omise-Signature', '')

print(f'Webhook received: {request.json.get("key")}')
print(f'Signature: {signature}')

# Verify signature
secret_key = os.environ.get('OMISE_SECRET_KEY')
expected_signature = hmac.new(
secret_key.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()

if not hmac.compare_digest(signature, expected_signature):
print('Invalid signature!')
return jsonify({'error': 'Invalid signature'}), 401

print('Signature verified โœ“')

# Process webhook
event = request.json
process_webhook(event)

# Always respond 200 quickly
return jsonify({'received': True}), 200

def process_webhook(event):
"""Process webhook event asynchronously"""
event_key = event.get('key')
data = event.get('data', {})

print(f'Processing {event_key}...')

# Handle different event types
if event_key == 'charge.complete':
handle_charge_complete(data)
elif event_key == 'charge.expire':
handle_charge_expire(data)
elif event_key == 'refund.create':
handle_refund_create(data)

def handle_charge_complete(charge):
print(f'Charge completed: {charge["id"]}')
# Update order status, send confirmation email, etc.

def handle_charge_expire(charge):
print(f'Charge expired: {charge["id"]}')
# Cancel order, notify user, etc.

def handle_refund_create(refund):
print(f'Refund created: {refund["id"]}')
# Update order status, notify user, etc.

if __name__ == '__main__':
print('Webhook server running on port 3000')
print('Start ngrok: ngrok http 3000')
app.run(port=3000, debug=True)

Testing with Webhook.siteโ€‹

Using Webhook.siteโ€‹

Webhook.site provides a temporary URL for testing webhooks without writing code.

Steps:

  1. Go to https://webhook.site
  2. Copy your unique URL (e.g., https://webhook.site/abc-123)
  3. Add this URL to Omise Dashboard webhooks
  4. Trigger test events in Omise
  5. View requests in real-time on webhook.site

Features:

  • View request headers, body, and query parameters
  • Inspect response status and body
  • Edit and customize responses
  • Export request data
  • Share webhook URL with team

Testing Webhook Signaturesโ€‹

Webhook.site allows you to verify signatures:

// JavaScript - Generate test signature for webhook.site
const crypto = require('crypto');

function generateWebhookSignature(payload, secretKey) {
return crypto
.createHmac('sha256', secretKey)
.update(JSON.stringify(payload))
.digest('hex');
}

// Test payload
const payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100000,
currency: 'THB',
status: 'successful'
}
};

const secretKey = 'skey_test_xxxxxxxxxx';
const signature = generateWebhookSignature(payload, secretKey);

console.log('Expected signature:', signature);
// Copy this signature to verify on webhook.site

Testing with Postmanโ€‹

Setting Up Postman for Webhook Testingโ€‹

Postman can send test webhook requests to your local endpoint.

Create a webhook test collection:

{
"info": {
"name": "Omise Webhook Tests",
"description": "Test webhook handlers"
},
"item": [
{
"name": "Charge Complete",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Omise-Signature",
"value": "{{signature}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"charge.complete\",\n \"data\": {\n \"id\": \"chrg_test_123\",\n \"amount\": 100000,\n \"currency\": \"THB\",\n \"status\": \"successful\"\n }\n}"
},
"url": {
"raw": "http://localhost:3000/webhooks",
"protocol": "http",
"host": ["localhost"],
"port": "3000",
"path": ["webhooks"]
}
}
}
]
}

Generating Signatures in Postmanโ€‹

Pre-request Script:

// Postman Pre-request Script
const CryptoJS = require('crypto-js');

// Get secret key from environment variable
const secretKey = pm.environment.get('OMISE_SECRET_KEY');

// Get request body
const payload = pm.request.body.raw;

// Generate HMAC signature
const signature = CryptoJS.HmacSHA256(payload, secretKey).toString();

// Set signature header
pm.environment.set('signature', signature);
console.log('Generated signature:', signature);

Testing Different Webhook Eventsโ€‹

# Ruby - Generate Postman collection for all webhook events
require 'json'

class PostmanCollectionGenerator
WEBHOOK_EVENTS = {
'charge.complete' => {
id: 'chrg_test_123',
amount: 100_000,
currency: 'THB',
status: 'successful'
},
'charge.expire' => {
id: 'chrg_test_124',
amount: 100_000,
currency: 'THB',
status: 'expired'
},
'refund.create' => {
id: 'rfnd_test_123',
charge: 'chrg_test_123',
amount: 100_000,
currency: 'THB'
},
'transfer.create' => {
id: 'trsf_test_123',
amount: 100_000,
currency: 'THB'
}
}.freeze

def self.generate
collection = {
info: {
name: 'Omise Webhook Tests',
description: 'Complete webhook event tests'
},
item: []
}

WEBHOOK_EVENTS.each do |event_key, data|
collection[:item] << create_request(event_key, data)
end

JSON.pretty_generate(collection)
end

def self.create_request(event_key, data)
{
name: event_key.split('.').map(&:capitalize).join(' '),
request: {
method: 'POST',
header: [
{
key: 'Content-Type',
value: 'application/json'
},
{
key: 'X-Omise-Signature',
value: '{{signature}}'
}
],
body: {
mode: 'raw',
raw: JSON.pretty_generate({
key: event_key,
data: data
})
},
url: {
raw: 'http://localhost:3000/webhooks',
protocol: 'http',
host: ['localhost'],
port: '3000',
path: ['webhooks']
}
}
}
end
end

# Generate and save collection
collection = PostmanCollectionGenerator.generate
File.write('omise_webhook_tests.postman_collection.json', collection)
puts 'Postman collection generated!'

Automated Webhook Testingโ€‹

Testing with Jest (JavaScript/Node.js)โ€‹

// webhook.test.js - Jest tests for webhook handler
const request = require('supertest');
const crypto = require('crypto');
const app = require('./app'); // Your Express app

describe('Webhook Handler', () => {
const SECRET_KEY = 'skey_test_xxxxxxxxxx';

function generateSignature(payload) {
return crypto
.createHmac('sha256', SECRET_KEY)
.update(JSON.stringify(payload))
.digest('hex');
}

describe('POST /webhooks', () => {
test('should accept valid webhook with correct signature', async () => {
const payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100000,
currency: 'THB',
status: 'successful'
}
};

const signature = generateSignature(payload);

const response = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

expect(response.status).toBe(200);
expect(response.body).toEqual({ received: true });
});

test('should reject webhook with invalid signature', async () => {
const payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100000,
currency: 'THB',
status: 'successful'
}
};

const response = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', 'invalid_signature')
.send(payload);

expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error');
});

test('should reject webhook with missing signature', async () => {
const payload = {
key: 'charge.complete',
data: { id: 'chrg_test_123' }
};

const response = await request(app)
.post('/webhooks')
.send(payload);

expect(response.status).toBe(401);
});

test('should handle charge.complete event', async () => {
const payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100000,
currency: 'THB',
status: 'successful'
}
};

const signature = generateSignature(payload);

const response = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

expect(response.status).toBe(200);

// Verify event was processed
// Check database, logs, etc.
});

test('should handle charge.expire event', async () => {
const payload = {
key: 'charge.expire',
data: {
id: 'chrg_test_124',
amount: 100000,
currency: 'THB',
status: 'expired'
}
};

const signature = generateSignature(payload);

const response = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

expect(response.status).toBe(200);
});

test('should handle duplicate webhooks idempotently', async () => {
const payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_125',
amount: 100000,
currency: 'THB',
status: 'successful'
}
};

const signature = generateSignature(payload);

// Send same webhook twice
const response1 = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

const response2 = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

expect(response1.status).toBe(200);
expect(response2.status).toBe(200);

// Verify event was only processed once
// Check database for single record
});

test('should respond within timeout limit', async () => {
const payload = {
key: 'charge.complete',
data: { id: 'chrg_test_126' }
};

const signature = generateSignature(payload);

const startTime = Date.now();

const response = await request(app)
.post('/webhooks')
.set('X-Omise-Signature', signature)
.send(payload);

const duration = Date.now() - startTime;

expect(response.status).toBe(200);
expect(duration).toBeLessThan(5000); // Must respond within 5 seconds
});
});
});

Testing with Pytest (Python)โ€‹

# test_webhooks.py - Pytest tests for webhook handler
import pytest
import json
import hmac
import hashlib
from flask import Flask
from app import app # Your Flask app

@pytest.fixture
def client():
"""Create test client"""
app.config['TESTING'] = True
with app.test_client() as client:
yield client

@pytest.fixture
def secret_key():
"""Test secret key"""
return 'skey_test_xxxxxxxxxx'

def generate_signature(payload, secret_key):
"""Generate HMAC signature for webhook"""
payload_str = json.dumps(payload)
signature = hmac.new(
secret_key.encode('utf-8'),
payload_str.encode('utf-8'),
hashlib.sha256
).hexdigest()
return signature

class TestWebhookHandler:
"""Test webhook handler"""

def test_valid_webhook_with_correct_signature(self, client, secret_key):
"""Should accept valid webhook with correct signature"""
payload = {
'key': 'charge.complete',
'data': {
'id': 'chrg_test_123',
'amount': 100000,
'currency': 'THB',
'status': 'successful'
}
}

signature = generate_signature(payload, secret_key)

response = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
)

assert response.status_code == 200
assert response.json['received'] == True

def test_reject_invalid_signature(self, client):
"""Should reject webhook with invalid signature"""
payload = {
'key': 'charge.complete',
'data': {'id': 'chrg_test_123'}
}

response = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': 'invalid_signature'
}
)

assert response.status_code == 401
assert 'error' in response.json

def test_reject_missing_signature(self, client):
"""Should reject webhook with missing signature"""
payload = {
'key': 'charge.complete',
'data': {'id': 'chrg_test_123'}
}

response = client.post(
'/webhooks',
data=json.dumps(payload),
headers={'Content-Type': 'application/json'}
)

assert response.status_code == 401

def test_handle_charge_complete(self, client, secret_key):
"""Should handle charge.complete event"""
payload = {
'key': 'charge.complete',
'data': {
'id': 'chrg_test_123',
'amount': 100000,
'currency': 'THB',
'status': 'successful'
}
}

signature = generate_signature(payload, secret_key)

response = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
)

assert response.status_code == 200
# Verify event was processed correctly
# Check database, logs, etc.

def test_handle_duplicate_webhooks(self, client, secret_key):
"""Should handle duplicate webhooks idempotently"""
payload = {
'key': 'charge.complete',
'data': {
'id': 'chrg_test_125',
'amount': 100000,
'currency': 'THB',
'status': 'successful'
}
}

signature = generate_signature(payload, secret_key)

# Send same webhook twice
response1 = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
)

response2 = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
)

assert response1.status_code == 200
assert response2.status_code == 200
# Verify event was only processed once

@pytest.mark.parametrize('event_key,expected_status', [
('charge.complete', 200),
('charge.expire', 200),
('refund.create', 200),
('transfer.create', 200),
])
def test_handle_different_events(self, client, secret_key, event_key, expected_status):
"""Should handle different event types"""
payload = {
'key': event_key,
'data': {'id': 'test_123'}
}

signature = generate_signature(payload, secret_key)

response = client.post(
'/webhooks',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
)

assert response.status_code == expected_status

Testing with RSpec (Ruby)โ€‹

# spec/webhooks_spec.rb - RSpec tests for webhook handler
require 'rails_helper'
require 'openssl'
require 'json'

RSpec.describe 'Webhooks', type: :request do
let(:secret_key) { 'skey_test_xxxxxxxxxx' }

def generate_signature(payload)
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
secret_key,
payload.to_json
)
end

describe 'POST /webhooks' do
context 'with valid signature' do
it 'accepts webhook and returns 200' do
payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100_000,
currency: 'THB',
status: 'successful'
}
}

signature = generate_signature(payload)

post '/webhooks',
params: payload.to_json,
headers: {
'Content-Type' => 'application/json',
'X-Omise-Signature' => signature
}

expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)['received']).to be true
end
end

context 'with invalid signature' do
it 'rejects webhook and returns 401' do
payload = {
key: 'charge.complete',
data: { id: 'chrg_test_123' }
}

post '/webhooks',
params: payload.to_json,
headers: {
'Content-Type' => 'application/json',
'X-Omise-Signature' => 'invalid_signature'
}

expect(response).to have_http_status(:unauthorized)
end
end

context 'with missing signature' do
it 'rejects webhook and returns 401' do
payload = {
key: 'charge.complete',
data: { id: 'chrg_test_123' }
}

post '/webhooks',
params: payload.to_json,
headers: { 'Content-Type' => 'application/json' }

expect(response).to have_http_status(:unauthorized)
end
end

context 'handling charge.complete event' do
it 'processes charge completion' do
payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_123',
amount: 100_000,
currency: 'THB',
status: 'successful'
}
}

signature = generate_signature(payload)

expect {
post '/webhooks',
params: payload.to_json,
headers: {
'Content-Type' => 'application/json',
'X-Omise-Signature' => signature
}
}.to change { ProcessedWebhook.count }.by(1)

expect(response).to have_http_status(:ok)
end
end

context 'handling duplicate webhooks' do
it 'processes webhook only once' do
payload = {
key: 'charge.complete',
data: {
id: 'chrg_test_125',
amount: 100_000,
currency: 'THB',
status: 'successful'
}
}

signature = generate_signature(payload)

# Send webhook twice
2.times do
post '/webhooks',
params: payload.to_json,
headers: {
'Content-Type' => 'application/json',
'X-Omise-Signature' => signature
}

expect(response).to have_http_status(:ok)
end

# Verify processed only once
expect(ProcessedWebhook.where(event_id: payload[:data][:id]).count).to eq(1)
end
end

context 'handling different event types' do
%w[charge.complete charge.expire refund.create transfer.create].each do |event_key|
it "handles #{event_key} event" do
payload = {
key: event_key,
data: { id: 'test_123' }
}

signature = generate_signature(payload)

post '/webhooks',
params: payload.to_json,
headers: {
'Content-Type' => 'application/json',
'X-Omise-Signature' => signature
}

expect(response).to have_http_status(:ok)
end
end
end
end
end

Webhook Mocking for Testingโ€‹

Creating Mock Webhook Serverโ€‹

// mock-webhook-server.js - Mock Omise webhook server for testing
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');

class MockWebhookServer {
constructor(options = {}) {
this.app = express();
this.webhookUrl = options.webhookUrl;
this.secretKey = options.secretKey;
this.port = options.port || 4000;

this.setupRoutes();
}

setupRoutes() {
this.app.use(express.json());

// Trigger test webhooks
this.app.post('/trigger/:event', async (req, res) => {
const eventType = req.params.event;
const payload = this.generatePayload(eventType, req.body);

try {
await this.sendWebhook(payload);
res.json({ success: true, event: payload });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

// List available events
this.app.get('/events', (req, res) => {
res.json({
events: [
'charge.complete',
'charge.expire',
'refund.create',
'transfer.create'
]
});
});
}

generatePayload(eventType, customData = {}) {
const basePayloads = {
'charge.complete': {
key: 'charge.complete',
data: {
id: `chrg_test_${Date.now()}`,
amount: 100000,
currency: 'THB',
status: 'successful',
created: new Date().toISOString(),
...customData
}
},
'charge.expire': {
key: 'charge.expire',
data: {
id: `chrg_test_${Date.now()}`,
amount: 100000,
currency: 'THB',
status: 'expired',
created: new Date().toISOString(),
...customData
}
},
'refund.create': {
key: 'refund.create',
data: {
id: `rfnd_test_${Date.now()}`,
charge: customData.charge || 'chrg_test_123',
amount: customData.amount || 100000,
currency: 'THB',
created: new Date().toISOString(),
...customData
}
}
};

return basePayloads[eventType] || basePayloads['charge.complete'];
}

generateSignature(payload) {
return crypto
.createHmac('sha256', this.secretKey)
.update(JSON.stringify(payload))
.digest('hex');
}

async sendWebhook(payload) {
const signature = this.generateSignature(payload);

console.log(`Sending webhook: ${payload.key}`);
console.log(`To: ${this.webhookUrl}`);

const response = await axios.post(this.webhookUrl, payload, {
headers: {
'Content-Type': 'application/json',
'X-Omise-Signature': signature
}
});

console.log(`Response: ${response.status}`);
return response.data;
}

start() {
this.app.listen(this.port, () => {
console.log(`Mock webhook server running on port ${this.port}`);
console.log(`Webhook URL: ${this.webhookUrl}`);
console.log(`\nTrigger webhooks:`);
console.log(` POST http://localhost:${this.port}/trigger/charge.complete`);
console.log(` POST http://localhost:${this.port}/trigger/charge.expire`);
console.log(` POST http://localhost:${this.port}/trigger/refund.create`);
});
}
}

// Usage
const server = new MockWebhookServer({
webhookUrl: 'http://localhost:3000/webhooks',
secretKey: 'skey_test_xxxxxxxxxx',
port: 4000
});

server.start();

// Export for testing
module.exports = MockWebhookServer;

Using Mock Server in Testsโ€‹

// webhook-integration.test.js - Integration tests using mock server
const MockWebhookServer = require('./mock-webhook-server');
const axios = require('axios');

describe('Webhook Integration Tests', () => {
let mockServer;

beforeAll(() => {
mockServer = new MockWebhookServer({
webhookUrl: 'http://localhost:3000/webhooks',
secretKey: 'skey_test_xxxxxxxxxx',
port: 4000
});
mockServer.start();
});

afterAll(() => {
// Stop mock server
});

test('should trigger and process charge.complete webhook', async () => {
const response = await axios.post('http://localhost:4000/trigger/charge.complete', {
amount: 150000,
currency: 'THB'
});

expect(response.status).toBe(200);
expect(response.data.success).toBe(true);
expect(response.data.event.key).toBe('charge.complete');

// Wait for webhook processing
await new Promise(resolve => setTimeout(resolve, 1000));

// Verify webhook was processed
// Check database, logs, etc.
});

test('should trigger and process refund.create webhook', async () => {
const response = await axios.post('http://localhost:4000/trigger/refund.create', {
charge: 'chrg_test_123',
amount: 50000
});

expect(response.status).toBe(200);
expect(response.data.event.key).toBe('refund.create');
});
});

CI/CD Integrationโ€‹

GitHub Actions Webhook Testingโ€‹

# .github/workflows/test-webhooks.yml
name: Test Webhooks

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
test-webhooks:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Setup test database
run: npm run db:setup
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

- name: Run webhook tests
run: npm test -- --testPathPattern=webhooks
env:
OMISE_SECRET_KEY: ${{ secrets.OMISE_SECRET_KEY_TEST }}
NODE_ENV: test

- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: webhook-test-results
path: test-results/

- name: Comment PR with test results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const results = fs.readFileSync('test-results/summary.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Webhook Test Results\n\n\`\`\`\n${results}\n\`\`\``
});

GitLab CI Webhook Testingโ€‹

# .gitlab-ci.yml
stages:
- test
- deploy

test-webhooks:
stage: test
image: node:18
services:
- postgres:13
variables:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/test_db
before_script:
- npm ci
- npm run db:setup
script:
- npm test -- --testPathPattern=webhooks
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
junit: test-results/junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
paths:
- coverage/
- test-results/
expire_in: 1 week

Best Practicesโ€‹

1. Verify Webhook Signaturesโ€‹

Always verify webhook signatures to prevent spoofing:

<?php
// PHP - Webhook signature verification
function verifyWebhookSignature($payload, $signature, $secretKey) {
$expectedSignature = hash_hmac('sha256', $payload, $secretKey);

// Use timing-safe comparison
return hash_equals($expectedSignature, $signature);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_OMISE_SIGNATURE'] ?? '';

if (!verifyWebhookSignature($payload, $signature, $secretKey)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}

// Process webhook
$event = json_decode($payload, true);
processWebhook($event);

http_response_code(200);
echo json_encode(['received' => true]);
?>

2. Handle Webhooks Idempotentlyโ€‹

Process duplicate webhooks safely:

// Go - Idempotent webhook processing
package main

import (
"database/sql"
"encoding/json"
"net/http"
"time"
)

type WebhookEvent struct {
Key string `json:"key"`
Data json.RawMessage `json:"data"`
}

type ProcessedWebhook struct {
EventID string
EventKey string
ProcessedAt time.Time
}

func handleWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB) {
var event WebhookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

// Extract event ID
var data map[string]interface{}
json.Unmarshal(event.Data, &data)
eventID := data["id"].(string)

// Check if already processed
var exists bool
err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM processed_webhooks WHERE event_id = $1)",
eventID,
).Scan(&exists)

if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}

if exists {
// Already processed, return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
return
}

// Process webhook
if err := processWebhookEvent(event); err != nil {
http.Error(w, "Processing error", http.StatusInternalServerError)
return
}

// Mark as processed
_, err = db.Exec(
"INSERT INTO processed_webhooks (event_id, event_key, processed_at) VALUES ($1, $2, $3)",
eventID, event.Key, time.Now(),
)

if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func processWebhookEvent(event WebhookEvent) error {
// Process the event
// Update order status, send notifications, etc.
return nil
}

3. Respond Quicklyโ€‹

Respond to webhooks within 30 seconds:

# Python - Async webhook processing
from flask import Flask, request, jsonify
import threading
import queue

app = Flask(__name__)
webhook_queue = queue.Queue()

def process_webhooks_async():
"""Background worker to process webhooks"""
while True:
try:
event = webhook_queue.get()

# Process event
process_webhook_event(event)

webhook_queue.task_done()
except Exception as e:
print(f'Error processing webhook: {e}')

# Start background worker
worker_thread = threading.Thread(target=process_webhooks_async, daemon=True)
worker_thread.start()

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
# Verify signature
if not verify_signature(request):
return jsonify({'error': 'Invalid signature'}), 401

# Queue for processing
event = request.json
webhook_queue.put(event)

# Respond immediately
return jsonify({'received': True}), 200

def process_webhook_event(event):
"""Process webhook event (runs in background)"""
event_key = event.get('key')
data = event.get('data', {})

try:
if event_key == 'charge.complete':
handle_charge_complete(data)
elif event_key == 'refund.create':
handle_refund_create(data)
# Handle other events...

except Exception as e:
print(f'Error in webhook processing: {e}')
# Log error, send alert, etc.

4. Log Webhook Eventsโ€‹

Comprehensive logging for debugging:

// JavaScript - Webhook logging
const winston = require('winston');

const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'webhooks-error.log', level: 'error' }),
new winston.transports.File({ filename: 'webhooks.log' })
]
});

app.post('/webhooks', (req, res) => {
const event = req.body;
const signature = req.headers['x-omise-signature'];

// Log received webhook
logger.info('Webhook received', {
event_key: event.key,
event_id: event.data?.id,
timestamp: new Date().toISOString(),
signature: signature
});

try {
// Verify signature
if (!verifySignature(req.body, signature)) {
logger.error('Invalid webhook signature', {
event_key: event.key,
signature: signature
});
return res.status(401).json({ error: 'Invalid signature' });
}

// Process webhook
processWebhook(event);

logger.info('Webhook processed successfully', {
event_key: event.key,
event_id: event.data?.id
});

res.json({ received: true });

} catch (error) {
logger.error('Webhook processing failed', {
event_key: event.key,
error: error.message,
stack: error.stack
});

res.status(500).json({ error: 'Processing failed' });
}
});

5. Monitor Webhook Healthโ€‹

Track webhook delivery and processing:

# Ruby - Webhook health monitoring
class WebhookMonitor
def self.record_webhook(event_key, status, duration_ms)
WebhookMetric.create(
event_key: event_key,
status: status,
duration_ms: duration_ms,
timestamp: Time.now
)
end

def self.health_check
recent_webhooks = WebhookMetric
.where('timestamp > ?', 1.hour.ago)

total = recent_webhooks.count
successful = recent_webhooks.where(status: 'success').count
failed = recent_webhooks.where(status: 'error').count

success_rate = total > 0 ? (successful.to_f / total * 100).round(2) : 0
avg_duration = recent_webhooks.average(:duration_ms)&.round(2) || 0

{
period: '1 hour',
total: total,
successful: successful,
failed: failed,
success_rate: success_rate,
avg_duration_ms: avg_duration,
status: success_rate >= 95 ? 'healthy' : 'degraded'
}
end

def self.alert_if_unhealthy
health = health_check

if health[:success_rate] < 95
send_alert(
"Webhook success rate dropped to #{health[:success_rate]}%",
severity: 'high'
)
end

if health[:avg_duration_ms] > 5000
send_alert(
"Webhook processing time increased to #{health[:avg_duration_ms]}ms",
severity: 'medium'
)
end
end

def self.send_alert(message, severity:)
# Send to monitoring service
puts "[#{severity.upcase}] #{message}"
end
end

Troubleshootingโ€‹

Common Issuesโ€‹

Issue: Webhooks not being receivedโ€‹

Possible causes:

  • Webhook URL not configured in Omise Dashboard
  • Firewall blocking requests
  • ngrok tunnel not running
  • Server not listening on correct port

Solution:

  1. Verify webhook URL in Omise Dashboard
  2. Check server logs for incoming requests
  3. Test with webhook.site first
  4. Verify ngrok is running: curl http://localhost:4040/api/tunnels

Issue: Signature verification failingโ€‹

Possible causes:

  • Using wrong secret key
  • Comparing with wrong payload format
  • Encoding issues

Solution:

// Debug signature verification
const receivedSignature = req.headers['x-omise-signature'];
const payload = JSON.stringify(req.body);
const secretKey = process.env.OMISE_SECRET_KEY;

const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(payload)
.digest('hex');

console.log('Received:', receivedSignature);
console.log('Expected:', expectedSignature);
console.log('Match:', receivedSignature === expectedSignature);

Issue: Webhook timing outโ€‹

Cause: Processing taking too long

Solution: Process asynchronously:

# Quick response, async processing
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
event = request.json

# Queue for background processing
celery_app.send_task('process_webhook', args=[event])

# Respond immediately
return jsonify({'received': True}), 200

Frequently Asked Questionsโ€‹

How long does Omise wait for webhook response?โ€‹

Omise waits 30 seconds for your endpoint to respond. If no response is received within 30 seconds, the webhook is marked as failed and will be retried.

How many times does Omise retry failed webhooks?โ€‹

Omise retries failed webhooks up to 10 times over 3 days with exponential backoff. The retry schedule is: 1h, 2h, 4h, 8h, 12h, 24h, 24h, 24h, 24h, 24h.

Can I replay webhooks?โ€‹

Yes, you can replay webhooks from the Omise Dashboard. Go to Settings > Webhooks, find the event, and click "Resend".

How do I test webhooks locally?โ€‹

Use ngrok to create a public URL that forwards to your localhost. Then configure that URL in the Omise Dashboard for test mode.

Do I need to verify webhook signatures?โ€‹

Yes, always verify webhook signatures to prevent spoofing. Omise signs all webhooks with HMAC-SHA256 using your secret key.

What should I return from my webhook endpoint?โ€‹

Return HTTP 200 status code within 30 seconds. The response body doesn't matter; Omise only checks the status code.

How do I handle duplicate webhooks?โ€‹

Use the event ID to track which events you've already processed. Store processed event IDs in your database and skip reprocessing.

Can I test webhooks in test mode?โ€‹

Yes, test mode sends webhooks just like production. Configure a test mode webhook endpoint in the Omise Dashboard.

What events trigger webhooks?โ€‹

Webhooks are triggered for charge, refund, transfer, customer, card, and dispute events. You can select which events to receive in the Omise Dashboard.

How do I debug webhook issues?โ€‹

Use ngrok's web interface (http://localhost:4040) to inspect webhook requests and responses. Enable detailed logging in your application and use webhook.site for initial testing.

Next Stepsโ€‹

  1. Set up ngrok for local webhook testing
  2. Implement signature verification in your webhook handler
  3. Add idempotency handling to prevent duplicate processing
  4. Write automated tests for your webhook handler
  5. Process webhooks asynchronously for better performance
  6. Add comprehensive logging for debugging
  7. Set up monitoring for webhook health
  8. Test all webhook events your application handles

Ready to deploy? Check out Production Checklist for going live.

Need test data? See Test Cards & Data.