Skip to main content

Webhooks

General Wisdom sends webhook notifications to your server when session lifecycle events occur. Webhooks allow your backend to react to events in real time without polling, enabling use cases like updating databases, triggering workflows, and maintaining audit logs.

Webhook Event Flow

sequenceDiagram
participant Platform as GW Platform
participant Webhook as Your Webhook Endpoint
participant DB as Your Database
participant Queue as Event Queue (optional)

Platform->>Webhook: POST /webhooks/gw (signed payload)
Webhook->>Webhook: Verify X-GW-Signature header
Webhook-->>Platform: 200 OK (within 5 seconds)
Webhook->>Queue: Enqueue event for processing
Queue->>DB: Update records asynchronously

Note over Platform,Webhook: If no 2xx response within 5s, GW retries with backoff

Supported Events

EventDescriptionTriggered When
session.createdA new session was purchased and launchedUser purchases session and is redirected to your app
session.extendedSession duration was extendedUser purchases additional time
session.endedSession has terminatedExpiration reached, user ends manually, or error
purchase.completedAn in-session purchase succeededToken deduction and item delivery confirmed
purchase.failedAn in-session purchase failedInsufficient balance, item unavailable, or system error

Webhook Payload Structure

All webhooks share a common envelope:

{
"event": "session.created",
"timestamp": "2026-05-07T12:00:00.000Z",
"webhookId": "wh_01HYX3KZNP5ABCDEF",
"data": {
// Event-specific payload (see below)
}
}

session.created

{
"event": "session.created",
"timestamp": "2026-05-07T12:00:00.000Z",
"webhookId": "wh_01HYX3KZNP5ABCDEF",
"data": {
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
"applicationId": "your-app-id",
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
"orgId": "org-789",
"email": "user@example.com",
"durationMinutes": 60,
"startTime": "2026-05-07T12:00:00.000Z",
"expiresAt": "2026-05-07T13:00:00.000Z",
"tokenCost": 30
}
}

session.extended

{
"event": "session.extended",
"timestamp": "2026-05-07T12:45:00.000Z",
"webhookId": "wh_01HYX4MRNQ7GHIJKL",
"data": {
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
"applicationId": "your-app-id",
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
"extensionMinutes": 30,
"previousExpiresAt": "2026-05-07T13:00:00.000Z",
"newExpiresAt": "2026-05-07T13:30:00.000Z",
"tokenCost": 15,
"newBalance": 55
}
}

session.ended

{
"event": "session.ended",
"timestamp": "2026-05-07T13:30:00.000Z",
"webhookId": "wh_01HYX5PTSB9MNOPQR",
"data": {
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
"applicationId": "your-app-id",
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
"reason": "expired",
"actualDurationMinutes": 90,
"totalTokensSpent": 45
}
}

purchase.completed

{
"event": "purchase.completed",
"timestamp": "2026-05-07T12:15:00.000Z",
"webhookId": "wh_01HYX6QUVD2STUVWX",
"data": {
"purchaseId": "purchase-1715000000-a1b2c3",
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
"applicationId": "your-app-id",
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
"itemId": "item-001",
"itemName": "Extra Report",
"tokenCost": 5,
"newBalance": 65,
"idempotencyKey": "sess-123-item-001-1715000000"
}
}

purchase.failed

{
"event": "purchase.failed",
"timestamp": "2026-05-07T12:16:00.000Z",
"webhookId": "wh_01HYX7RWXF4YZABCD",
"data": {
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
"applicationId": "your-app-id",
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
"itemId": "item-003",
"errorCode": "INSUFFICIENT_BALANCE",
"errorMessage": "Insufficient SMART token balance",
"currentBalance": 3,
"required": 25
}
}

Signature Verification

Every webhook request includes an X-GW-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the webhook is authentic.

Verification Algorithm

signature = HMAC-SHA256(webhook_secret, raw_request_body)

Node.js / Express Implementation

import crypto from 'crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.GW_WEBHOOK_SECRET;

// Use raw body for signature verification
app.post(
'/webhooks/gw',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-gw-signature'] as string;
const rawBody = req.body as Buffer;

// Verify signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');

if (!crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
)) {
console.error('[Webhook] Invalid signature');
return res.status(401).json({ error: 'Invalid signature' });
}

// Parse the verified payload
const event = JSON.parse(rawBody.toString());

// Respond immediately, process asynchronously
res.status(200).json({ received: true });

// Handle event
processWebhookEvent(event);
}
);

Go Implementation

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}

signature := r.Header.Get("X-GW-Signature")
if !verifySignature(body, signature) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))

// Process asynchronously
go processEvent(body)
}

func verifySignature(body []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}

Python Implementation

import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['GW_WEBHOOK_SECRET']

@app.route('/webhooks/gw', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-GW-Signature')
raw_body = request.get_data()

expected = hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()

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

event = request.get_json()
# Process event asynchronously (e.g., enqueue to Celery)
process_event.delay(event)

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

Retry Policy

If your endpoint does not respond with a 2xx status code within 5 seconds, General Wisdom retries with exponential backoff:

AttemptDelay After Failure
1st retry30 seconds
2nd retry2 minutes
3rd retry10 minutes
4th retry1 hour
5th retry6 hours

After 5 failed retries, the webhook is marked as failed and no further attempts are made for that event. Failed webhooks are available for manual replay via the developer portal.

caution

Respond to webhooks within 5 seconds. Move heavy processing to a background queue to avoid timeouts and retries.

Idempotency

Each webhook delivery includes a unique webhookId. Use this to deduplicate events in case of retries:

async function processWebhookEvent(event: WebhookEvent) {
// Check if we already processed this webhook
const existing = await db.webhookLog.findUnique({
where: { webhookId: event.webhookId },
});

if (existing) {
console.log(`[Webhook] Already processed ${event.webhookId}, skipping`);
return;
}

// Process the event
await handleEvent(event);

// Record that we processed it
await db.webhookLog.create({
data: {
webhookId: event.webhookId,
event: event.event,
processedAt: new Date(),
},
});
}

Event Handling Patterns

Router Pattern

async function processWebhookEvent(event: WebhookEvent) {
switch (event.event) {
case 'session.created':
await handleSessionCreated(event.data);
break;
case 'session.extended':
await handleSessionExtended(event.data);
break;
case 'session.ended':
await handleSessionEnded(event.data);
break;
case 'purchase.completed':
await handlePurchaseCompleted(event.data);
break;
case 'purchase.failed':
await handlePurchaseFailed(event.data);
break;
default:
console.warn(`[Webhook] Unknown event type: ${event.event}`);
}
}

Example Handlers

async function handleSessionCreated(data: SessionCreatedData) {
// Provision user in your database
await db.user.upsert({
where: { gwUserId: data.userId },
create: {
gwUserId: data.userId,
email: data.email,
orgId: data.orgId,
firstSessionAt: new Date(),
},
update: { lastSessionAt: new Date() },
});

// Record session
await db.session.create({
data: {
sessionId: data.sessionId,
userId: data.userId,
startedAt: new Date(data.startTime),
expiresAt: new Date(data.expiresAt),
durationMinutes: data.durationMinutes,
},
});
}

async function handleSessionEnded(data: SessionEndedData) {
// Update session record
await db.session.update({
where: { sessionId: data.sessionId },
data: {
endedAt: new Date(),
reason: data.reason,
actualDuration: data.actualDurationMinutes,
},
});

// Trigger post-session workflow
await triggerSessionReport(data.sessionId);
}

async function handlePurchaseCompleted(data: PurchaseCompletedData) {
// Record purchase in your system
await db.purchase.create({
data: {
purchaseId: data.purchaseId,
sessionId: data.sessionId,
userId: data.userId,
itemId: data.itemId,
tokenCost: data.tokenCost,
completedAt: new Date(),
},
});

// Deliver the purchased item
await deliverItem(data.userId, data.itemId);
}

Registering Your Webhook Endpoint

Configure your webhook URL in the General Wisdom developer portal under your application settings:

  1. Navigate to Applications in the developer portal
  2. Select your application
  3. Go to Webhooks settings
  4. Enter your endpoint URL (must be HTTPS)
  5. Select which events to subscribe to
  6. Save and copy the generated webhook secret

Endpoint Requirements

  • Must be reachable over HTTPS (TLS 1.2+)
  • Must respond with 2xx within 5 seconds
  • Must verify the X-GW-Signature header
  • Should use a dedicated path (e.g., /webhooks/gw)

Testing Webhooks

Using the Developer Portal

The portal provides a "Send Test Event" button for each event type. This sends a real webhook with test data to your configured endpoint.

Local Development with ngrok

For local development, use ngrok to expose your local server:

ngrok http 3001
# Use the HTTPS URL (e.g., https://abc123.ngrok.io/webhooks/gw)

Manual Testing with curl

# Generate a test signature
BODY='{"event":"session.created","timestamp":"2026-05-07T12:00:00Z","webhookId":"wh_test_123","data":{"sessionId":"test-session","userId":"test-user","applicationId":"your-app-id","durationMinutes":60}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d' ' -f2)

# Send test webhook
curl -X POST http://localhost:3001/webhooks/gw \
-H "Content-Type: application/json" \
-H "X-GW-Signature: $SIGNATURE" \
-d "$BODY"

Security Considerations

  • Always verify signatures - Never process unverified webhooks
  • Use timing-safe comparison - Prevent timing attacks on signature verification
  • Use HTTPS only - Webhook payloads contain user data
  • Store the webhook secret securely - Use environment variables or a secrets manager
  • Log webhook failures - Monitor for delivery issues
  • Implement idempotency - Retries can deliver the same event multiple times

Next Steps