Webhooks API
Complete API reference for managing webhooks. Receive real-time HTTP notifications when events occur in your PUGUH organization.
Endpoints Overview
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/webhooks/endpoints | List webhook endpoints |
POST | /api/v1/webhooks/endpoints | Create a webhook endpoint |
GET | /api/v1/webhooks/endpoints/{id} | Get endpoint details |
PATCH | /api/v1/webhooks/endpoints/{id} | Update an endpoint |
DELETE | /api/v1/webhooks/endpoints/{id} | Delete an endpoint |
GET | /api/v1/webhooks/events | List available event types |
GET | /api/v1/webhooks/endpoints/{id}/deliveries | List delivery attempts |
POST | /api/v1/webhooks/deliveries/{id}/retry | Retry a failed delivery |
List Webhook Endpoints
GET /api/v1/webhooks/endpoints Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | int | 1 | Page number |
page_size | int | 20 | Results per page (max 100) |
is_active | bool | - | Filter by active status |
Response
{
"items": [
{
"id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"url": "https://example.com/webhooks/puguh",
"description": "Production webhook receiver",
"events": ["user.created", "user.updated", "auth.login"],
"is_active": true,
"created_at": "2026-02-15T08:00:00Z",
"updated_at": "2026-02-18T12:30:00Z"
}
],
"total": 3,
"page": 1,
"page_size": 20,
"has_next": false,
"has_prev": false
} Create Webhook Endpoint
POST /api/v1/webhooks/endpoints
Content-Type: application/json Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS URL that will receive webhook payloads |
events | string[] | Yes | List of event types to subscribe to |
description | string | No | Human-readable description for this endpoint |
secret | string | No | Custom signing secret. If omitted, one is auto-generated |
{
"url": "https://example.com/webhooks/puguh",
"events": ["user.created", "user.updated", "organization.updated"],
"description": "Production webhook receiver",
"secret": "whsec_your_custom_secret_key"
} Response 201 Created
{
"id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"url": "https://example.com/webhooks/puguh",
"description": "Production webhook receiver",
"events": ["user.created", "user.updated", "organization.updated"],
"signing_secret": "whsec_your_custom_secret_key",
"is_active": true,
"created_at": "2026-02-20T10:30:00Z",
"updated_at": "2026-02-20T10:30:00Z"
} Important
The signing_secret is only returned in the create response. Store it securely — you will need it to verify webhook signatures. If you lose it, you must rotate the secret by updating the endpoint.
Get Webhook Endpoint
GET /api/v1/webhooks/endpoints/{id} Response
{
"id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"url": "https://example.com/webhooks/puguh",
"description": "Production webhook receiver",
"events": ["user.created", "user.updated", "organization.updated"],
"is_active": true,
"created_at": "2026-02-15T08:00:00Z",
"updated_at": "2026-02-18T12:30:00Z",
"last_delivery_at": "2026-02-20T09:15:00Z",
"delivery_stats": {
"total": 142,
"successful": 139,
"failed": 3
}
} Update Webhook Endpoint
PATCH /api/v1/webhooks/endpoints/{id}
Content-Type: application/json Request Body
All fields are optional. Only include fields you want to change.
| Field | Type | Description |
|---|---|---|
url | string | New HTTPS URL |
events | string[] | Replace subscribed event types |
description | string | Update description |
secret | string | Rotate the signing secret |
is_active | bool | Enable or disable the endpoint |
{
"events": ["user.created", "user.updated", "user.deleted", "auth.login"],
"is_active": true
} Response
{
"id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"url": "https://example.com/webhooks/puguh",
"description": "Production webhook receiver",
"events": ["user.created", "user.updated", "user.deleted", "auth.login"],
"is_active": true,
"created_at": "2026-02-15T08:00:00Z",
"updated_at": "2026-02-20T11:00:00Z"
} Delete Webhook Endpoint
DELETE /api/v1/webhooks/endpoints/{id} Permanently removes the webhook endpoint and stops all future deliveries.
Response 204 No Content
Returns an empty response on success.
Warning
Deleting a webhook endpoint is permanent. Any in-flight deliveries will be cancelled. If you want to temporarily stop receiving events, use PATCH to set is_active to false instead.
List Event Types
GET /api/v1/webhooks/events Returns all event types available for webhook subscriptions.
Response
[
{
"type": "user.created",
"category": "user",
"description": "A new user has been created"
},
{
"type": "user.updated",
"category": "user",
"description": "A user profile has been updated"
},
{
"type": "organization.created",
"category": "organization",
"description": "A new organization has been created"
}
] Available Event Types
| Category | Event Type | Description |
|---|---|---|
| User | user.created | A new user has been created |
| User | user.updated | A user profile has been updated |
| User | user.deleted | A user has been deleted |
| Organization | organization.created | A new organization has been created |
| Organization | organization.updated | An organization has been updated |
| Member | member.invited | A user has been invited to an organization |
| Member | member.joined | A user has joined an organization |
| Member | member.removed | A member has been removed from an organization |
| Application | application.created | A new application has been created |
| Application | application.updated | An application has been updated |
| Application | application.deleted | An application has been deleted |
| Auth | auth.login | A user has logged in |
| Auth | auth.logout | A user has logged out |
| Auth | auth.password_changed | A user has changed their password |
| Billing | billing.subscription.created | A new subscription has been created |
| Billing | billing.invoice.paid | An invoice has been paid |
List Deliveries
GET /api/v1/webhooks/endpoints/{id}/deliveries Returns delivery attempts for a specific webhook endpoint.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | int | 1 | Page number |
page_size | int | 20 | Results per page (max 100) |
status | string | - | Filter by status: success, failed, pending |
event | string | - | Filter by event type (e.g. user.created) |
Response
{
"items": [
{
"id": "del-a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"webhook_id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"event": "user.created",
"status": "success",
"http_status": 200,
"attempt": 1,
"max_attempts": 5,
"request_body": {
"event": "user.created",
"timestamp": "2026-02-20T10:30:00Z",
"data": {
"id": "usr-uuid",
"email": "newuser@example.com"
},
"webhook_id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"delivery_id": "del-a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d"
},
"response_body": "OK",
"duration_ms": 120,
"delivered_at": "2026-02-20T10:30:01Z",
"next_retry_at": null
},
{
"id": "del-b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"webhook_id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"event": "auth.login",
"status": "failed",
"http_status": 500,
"attempt": 3,
"max_attempts": 5,
"response_body": "Internal Server Error",
"duration_ms": 5032,
"delivered_at": "2026-02-20T09:15:05Z",
"next_retry_at": "2026-02-20T10:15:05Z"
}
],
"total": 142,
"page": 1,
"page_size": 20,
"has_next": true,
"has_prev": false
} Retry Failed Delivery
POST /api/v1/webhooks/deliveries/{id}/retry Manually retry a failed webhook delivery. The delivery is re-queued immediately regardless of the retry schedule.
Response
{
"id": "del-b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"status": "pending",
"attempt": 4,
"max_attempts": 5,
"next_retry_at": null,
"queued_at": "2026-02-20T11:00:00Z"
} Retry Policy
PUGUH uses exponential backoff for automatic retries: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours. After 5 failed attempts, the delivery is marked as permanently failed. Use this endpoint to manually retry at any time.
Webhook Payload Format
Every webhook delivery sends a JSON payload with a consistent structure:
{
"event": "user.created",
"timestamp": "2026-02-20T10:30:00Z",
"data": {
"id": "usr-a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"email": "newuser@example.com",
"full_name": "Jane Doe",
"created_at": "2026-02-20T10:30:00Z"
},
"webhook_id": "wh-3f8a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"delivery_id": "del-a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d"
} Payload Fields
| Field | Type | Description |
|---|---|---|
event | string | The event type that triggered this delivery |
timestamp | string | ISO 8601 timestamp of when the event occurred |
data | object | Event-specific payload data (varies by event type) |
webhook_id | string | The webhook endpoint ID that received this delivery |
delivery_id | string | Unique identifier for this specific delivery attempt |
HTTP Headers Sent with Deliveries
| Header | Description |
|---|---|
Content-Type | application/json |
X-Puguh-Signature | HMAC-SHA256 signature for payload verification |
X-Puguh-Event | The event type (e.g. user.created) |
X-Puguh-Delivery-ID | Unique delivery identifier |
X-Puguh-Timestamp | Unix timestamp of the delivery for replay protection |
User-Agent | PUGUH-Webhooks/1.0 |
Signature Verification
Every webhook delivery includes an X-Puguh-Signature header containing an HMAC-SHA256 signature. You must verify this signature to confirm the request came from PUGUH and was not tampered with.
How Signatures Work
- PUGUH concatenates the delivery timestamp and the raw JSON body:
timestamp.body - PUGUH computes an HMAC-SHA256 using your endpoint's signing secret as the key
- The signature is sent in the
X-Puguh-Signatureheader assha256=HEX_DIGEST - Your server must recompute the signature and compare it using a constant-time comparison
Python Verification Example
import hmac
import hashlib
import time
def verify_webhook_signature(
payload: bytes,
signature_header: str,
timestamp_header: str,
secret: str,
tolerance_seconds: int = 300
) -> bool:
"""Verify PUGUH webhook signature.
Args:
payload: Raw request body bytes.
signature_header: Value of X-Puguh-Signature header.
timestamp_header: Value of X-Puguh-Timestamp header.
secret: Your webhook endpoint signing secret.
tolerance_seconds: Max age in seconds (default 5 min).
Returns:
True if signature is valid and timestamp is within tolerance.
"""
# 1. Check timestamp to prevent replay attacks
try:
timestamp = int(timestamp_header)
except (ValueError, TypeError):
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
# 2. Compute expected signature
signed_content = f"{timestamp}.".encode() + payload
expected = hmac.new(
secret.encode(),
signed_content,
hashlib.sha256
).hexdigest()
# 3. Compare using constant-time comparison
expected_sig = f"sha256={expected}"
return hmac.compare_digest(expected_sig, signature_header)
# Usage in a Flask/FastAPI handler
from fastapi import Request, HTTPException
WEBHOOK_SECRET = "whsec_your_signing_secret"
@app.post("/webhooks/puguh")
async def handle_webhook(request: Request):
payload = await request.body()
signature = request.headers.get("X-Puguh-Signature", "")
timestamp = request.headers.get("X-Puguh-Timestamp", "")
if not verify_webhook_signature(payload, signature, timestamp, WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
event = request.headers.get("X-Puguh-Event")
data = await request.json()
if event == "user.created":
handle_user_created(data["data"])
elif event == "billing.invoice.paid":
handle_invoice_paid(data["data"])
return {"received": True} TypeScript Verification Example
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhookSignature(
payload: string,
signatureHeader: string,
timestampHeader: string,
secret: string,
toleranceSeconds: number = 300
): boolean {
// 1. Check timestamp to prevent replay attacks
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
// 2. Compute expected signature
const signedContent = `${timestamp}.${payload}`;
const expectedHex = createHmac("sha256", secret)
.update(signedContent)
.digest("hex");
const expectedSig = `sha256=${expectedHex}`;
// 3. Compare using constant-time comparison
if (expectedSig.length !== signatureHeader.length) return false;
return timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(signatureHeader)
);
}
// Usage in an Express handler
import express from "express";
const WEBHOOK_SECRET = process.env.PUGUH_WEBHOOK_SECRET!;
app.post(
"/webhooks/puguh",
express.raw({ type: "application/json" }),
(req, res) => {
const payload = req.body.toString();
const signature = req.headers["x-puguh-signature"] as string;
const timestamp = req.headers["x-puguh-timestamp"] as string;
if (!verifyWebhookSignature(payload, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).json({ detail: "Invalid signature" });
}
const event = req.headers["x-puguh-event"] as string;
const data = JSON.parse(payload);
switch (event) {
case "user.created":
handleUserCreated(data.data);
break;
case "billing.invoice.paid":
handleInvoicePaid(data.data);
break;
}
res.json({ received: true });
}
); Security Tip
Always verify the X-Puguh-Timestamp header to prevent replay attacks. Reject any delivery older than 5 minutes. Always use constant-time comparison (e.g. hmac.compare_digest in Python, timingSafeEqual in Node.js) to prevent timing attacks.
Best Practices
Respond Quickly
Your webhook endpoint should return a 2xx status code within 10 seconds. If processing takes longer, accept the webhook immediately and process the event asynchronously using a background job queue.
Handle Duplicate Deliveries
Use the delivery_id field to deduplicate events. In rare cases (network timeouts, retries), the same event may be delivered more than once. Store processed delivery IDs and skip duplicates.
Use HTTPS Endpoints
Webhook URLs must use HTTPS. PUGUH will reject any endpoint configured with an HTTP URL.
Monitor Delivery Health
Use the List Deliveries endpoint to monitor failure rates. If an endpoint consistently fails, PUGUH will automatically disable it after 5 consecutive failures and send a notification to the organization owner.
Error Responses
All error responses use HTTP status codes with a plain detail message. No wrapper object.
400 Bad Request
{
"detail": "Invalid URL: must be HTTPS"
} 404 Not Found
{
"detail": "Webhook endpoint not found"
} 409 Conflict
{
"detail": "Delivery is already in pending state"
} 422 Unprocessable Entity
{
"detail": "Invalid event type: user.unknown. Use GET /webhooks/events for available types."
} Related
- Authentication API — How to authenticate API requests
- Organizations API — Manage organizations and members
- Python SDK — Use webhooks via the Python SDK
- TypeScript SDK — Use webhooks via the TypeScript SDK