> ## Documentation Index
> Fetch the complete documentation index at: https://docs.profy.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Event Handling

> Build a secure and reliable Webhook endpoint to handle real-time event notifications from the Profy platform, including user authorization and billing events

<Note>
  The Webhook event system is continuously evolving. The event types and signature formats shown in this document represent the recommended implementation patterns — refer to the latest platform documentation for specifics.
</Note>

## What You'll Build

This tutorial walks you through building a Webhook receiver service that securely receives and processes real-time events pushed by the Profy platform. You'll implement signature verification, event dispatching, idempotent processing, and asynchronous consumption.

```mermaid theme={null}
sequenceDiagram
    participant Profy as Profy Platform
    participant App as Your Application
    participant DB as Database / Queue

    Profy->>App: POST /webhook (JSON + X-Profy-Signature)
    App->>App: Verify HMAC-SHA256 signature
    App->>App: Deduplicate by event_id
    App-->>Profy: 200 OK (respond immediately)
    App->>DB: Process event asynchronously
```

## Webhook Event Types

| Event Type                     | Trigger                                      | Payload Example                                                                                 |
| ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `user.authorized`              | User completes OAuth authorization           | `{ "user_id": "u_abc", "app_id": "app_123", "scopes": ["events:write"] }`                       |
| `user.revoked`                 | User revokes app authorization               | `{ "user_id": "u_abc", "app_id": "app_123" }`                                                   |
| `billing.event_processed`      | Billing event charge succeeded               | `{ "user_id": "u_abc", "event_name": "ai_generation", "amount": 10, "balance_remaining": 490 }` |
| `billing.insufficient_balance` | User has insufficient balance, charge failed | `{ "user_id": "u_abc", "event_name": "ai_generation", "amount": 10, "balance": 3 }`             |

JSON structure of each Webhook request:

```json theme={null}
{
  "event_id": "evt_20260602_xK9mN3",
  "event_type": "user.authorized",
  "created_at": "2026-06-02T09:15:00Z",
  "payload": {
    "user_id": "u_abc",
    "app_id": "app_123",
    "scopes": ["events:write"]
  }
}
```

## Prerequisites

<CardGroup cols={3}>
  <Card title="Profy App" icon="grid-2">
    Created an App in Profy Studio and obtained the Client ID
  </Card>

  <Card title="Webhook URL" icon="link">
    Configured a publicly accessible Webhook receiver URL in Studio
  </Card>

  <Card title="Webhook Secret" icon="key">
    Obtained the Webhook signing secret from Studio (used to verify request origin)
  </Card>
</CardGroup>

## Step 1: Register the Webhook URL

Complete the Webhook configuration in Profy Studio:

1. Go to **App Management** → Select your App → **Webhook Settings**
2. Enter your Webhook receiver URL (e.g., `https://your-app.com/api/webhook`)
3. Select the event types you want to subscribe to
4. Save and copy the auto-generated **Webhook Secret**

```env .env theme={null}
PROFY_WEBHOOK_SECRET=whsec_your_webhook_secret_here
```

<Warning>
  The Webhook Secret is the sole credential for verifying request origin. Keep it safe — never commit it to version control or expose it to clients.
</Warning>

## Step 2: Create the Webhook Receiver Endpoint

<CodeGroup>
  ```typescript webhook.ts (Express) theme={null}
  import express from "express";
  import { verifySignature } from "./verify";
  import { handleWebhookEvent } from "./handler";

  const app = express();

  app.post(
    "/api/webhook",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      const signature = req.headers["x-profy-signature"] as string;
      const body = req.body as Buffer;

      if (!verifySignature(body, signature)) {
        res.status(401).json({ error: "Invalid signature" });
        return;
      }

      const event = JSON.parse(body.toString());

      res.status(200).json({ received: true });

      handleWebhookEvent(event).catch(console.error);
    }
  );

  app.listen(3000);
  ```

  ```typescript webhook.ts (Hono) theme={null}
  import { Hono } from "hono";
  import { verifySignature } from "./verify";
  import { handleWebhookEvent } from "./handler";

  const app = new Hono();

  app.post("/api/webhook", async (c) => {
    const signature = c.req.header("x-profy-signature") ?? "";
    const body = await c.req.arrayBuffer();
    const rawBody = Buffer.from(body);

    if (!verifySignature(rawBody, signature)) {
      return c.json({ error: "Invalid signature" }, 401);
    }

    const event = JSON.parse(rawBody.toString());

    c.executionCtx.waitUntil(handleWebhookEvent(event));

    return c.json({ received: true }, 200);
  });

  export default app;
  ```

  ```python webhook.py (FastAPI) theme={null}
  from fastapi import FastAPI, Request, HTTPException
  import asyncio
  from verify import verify_signature
  from handler import handle_webhook_event

  app = FastAPI()

  @app.post("/api/webhook")
  async def webhook(request: Request):
      signature = request.headers.get("x-profy-signature", "")
      body = await request.body()

      if not verify_signature(body, signature):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()

      asyncio.create_task(handle_webhook_event(event))

      return {"received": True}
  ```
</CodeGroup>

<Tip>
  Return `200` first, then process asynchronously. The Profy platform triggers retries when it doesn't receive a response in time — responding quickly prevents duplicate deliveries.
</Tip>

## Step 3: Verify Signatures

Profy signs the request body using HMAC-SHA256, with the signature passed via the `X-Profy-Signature` header. The format is `sha256=<hex_digest>`.

<CodeGroup>
  ```typescript verify.ts theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const WEBHOOK_SECRET = process.env.PROFY_WEBHOOK_SECRET!;

  export function verifySignature(body: Buffer, signature: string): boolean {
    if (!signature.startsWith("sha256=")) return false;

    const expected = createHmac("sha256", WEBHOOK_SECRET)
      .update(body)
      .digest("hex");

    const received = signature.slice("sha256=".length);

    if (expected.length !== received.length) return false;

    return timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(received, "hex")
    );
  }
  ```

  ```python verify.py theme={null}
  import hmac
  import hashlib
  import os

  WEBHOOK_SECRET = os.environ["PROFY_WEBHOOK_SECRET"]

  def verify_signature(body: bytes, signature: str) -> bool:
      if not signature.startswith("sha256="):
          return False

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

      received = signature[len("sha256="):]

      return hmac.compare_digest(expected, received)
  ```
</CodeGroup>

<Warning>
  You must use `timingSafeEqual` (Node) or `hmac.compare_digest` (Python) for comparison. Regular string comparison is vulnerable to timing attacks.
</Warning>

## Step 4: Handle Events

Dispatch to the appropriate handler based on `event_type`.

<CodeGroup>
  ```typescript handler.ts theme={null}
  interface WebhookEvent {
    event_id: string;
    event_type: string;
    created_at: string;
    payload: Record<string, unknown>;
  }

  export async function handleWebhookEvent(event: WebhookEvent) {
    switch (event.event_type) {
      case "user.authorized":
        await onUserAuthorized(event.payload);
        break;
      case "user.revoked":
        await onUserRevoked(event.payload);
        break;
      case "billing.event_processed":
        await onBillingProcessed(event.payload);
        break;
      case "billing.insufficient_balance":
        await onInsufficientBalance(event.payload);
        break;
      default:
        console.warn(`Unhandled event type: ${event.event_type}`);
    }
  }

  async function onUserAuthorized(payload: Record<string, unknown>) {
    const userId = payload.user_id as string;
    await db.user.upsert({ externalId: userId, status: "active" });
  }

  async function onUserRevoked(payload: Record<string, unknown>) {
    const userId = payload.user_id as string;
    await db.user.update({ externalId: userId, status: "revoked" });
    await tokenStore.delete(userId);
  }

  async function onBillingProcessed(payload: Record<string, unknown>) {
    const { user_id, event_name, amount, balance_remaining } = payload as {
      user_id: string;
      event_name: string;
      amount: number;
      balance_remaining: number;
    };
    await db.billingLog.create({ userId: user_id, event_name, amount, balance_remaining });
  }

  async function onInsufficientBalance(payload: Record<string, unknown>) {
    const userId = payload.user_id as string;
    await notificationService.send(userId, "Insufficient balance — some features may be restricted");
  }
  ```

  ```python handler.py theme={null}
  from typing import Any

  async def handle_webhook_event(event: dict[str, Any]) -> None:
      event_type = event["event_type"]
      payload = event["payload"]

      handlers = {
          "user.authorized": on_user_authorized,
          "user.revoked": on_user_revoked,
          "billing.event_processed": on_billing_processed,
          "billing.insufficient_balance": on_insufficient_balance,
      }

      handler = handlers.get(event_type)
      if handler:
          await handler(payload)
      else:
          print(f"Unhandled event type: {event_type}")


  async def on_user_authorized(payload: dict[str, Any]) -> None:
      user_id = payload["user_id"]
      await db.user.upsert(external_id=user_id, status="active")


  async def on_user_revoked(payload: dict[str, Any]) -> None:
      user_id = payload["user_id"]
      await db.user.update(external_id=user_id, status="revoked")
      await token_store.delete(user_id)


  async def on_billing_processed(payload: dict[str, Any]) -> None:
      await db.billing_log.create(
          user_id=payload["user_id"],
          event_name=payload["event_name"],
          amount=payload["amount"],
          balance_remaining=payload["balance_remaining"],
      )


  async def on_insufficient_balance(payload: dict[str, Any]) -> None:
      user_id = payload["user_id"]
      await notification_service.send(user_id, "Insufficient balance — some features may be restricted")
  ```
</CodeGroup>

## Step 5: Idempotent Processing

Network issues or timeout retries can cause the same event to be delivered multiple times. Use `event_id` for idempotent deduplication.

<CodeGroup>
  ```typescript idempotency.ts theme={null}
  const PROCESSED_TTL_MS = 7 * 24 * 60 * 60 * 1000;

  export async function isProcessed(eventId: string): Promise<boolean> {
    const existing = await redis.get(`webhook:processed:${eventId}`);
    return existing !== null;
  }

  export async function markProcessed(eventId: string): Promise<void> {
    await redis.set(`webhook:processed:${eventId}`, "1", "PX", PROCESSED_TTL_MS);
  }
  ```

  ```python idempotency.py theme={null}
  PROCESSED_TTL_SECONDS = 7 * 24 * 60 * 60

  async def is_processed(event_id: str) -> bool:
      return await redis.exists(f"webhook:processed:{event_id}")

  async def mark_processed(event_id: str) -> None:
      await redis.set(
          f"webhook:processed:{event_id}",
          "1",
          ex=PROCESSED_TTL_SECONDS,
      )
  ```
</CodeGroup>

Add idempotency checks to the main processing flow:

<CodeGroup>
  ```typescript handler.ts theme={null}
  import { isProcessed, markProcessed } from "./idempotency";

  export async function handleWebhookEvent(event: WebhookEvent) {
    if (await isProcessed(event.event_id)) return;

    await markProcessed(event.event_id);

    switch (event.event_type) {
      // ...
    }
  }
  ```

  ```python handler.py theme={null}
  from idempotency import is_processed, mark_processed

  async def handle_webhook_event(event: dict[str, Any]) -> None:
      if await is_processed(event["event_id"]):
          return

      await mark_processed(event["event_id"])

      # ...
  ```
</CodeGroup>

<Tip>
  If you don't have Redis, you can also achieve idempotency using a database `UNIQUE(event_id)` constraint. A failed insert indicates the event was already processed.
</Tip>

## Step 6: Response and Retry Behavior

### Response Guidelines

| Your Response      | Profy's Behavior                                                     |
| ------------------ | -------------------------------------------------------------------- |
| `2xx`              | Delivery successful — no retry                                       |
| `4xx` (except 429) | Delivery failed — no retry (treated as permanent client error)       |
| `429`              | Rate limited — retry later                                           |
| `5xx` / Timeout    | Exponential backoff retry (max 5 attempts: 30s → 2m → 8m → 30m → 2h) |

### Best Practices

<CodeGroup>
  ```typescript webhook.ts (Express) theme={null}
  app.post(
    "/api/webhook",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      const signature = req.headers["x-profy-signature"] as string;
      const body = req.body as Buffer;

      if (!verifySignature(body, signature)) {
        res.status(401).json({ error: "Invalid signature" });
        return;
      }

      const event = JSON.parse(body.toString());

      res.status(200).json({ received: true });

      handleWebhookEvent(event).catch((err) => {
        console.error(`Failed to process event ${event.event_id}:`, err);
      });
    }
  );
  ```

  ```python webhook.py (FastAPI) theme={null}
  @app.post("/api/webhook")
  async def webhook(request: Request):
      signature = request.headers.get("x-profy-signature", "")
      body = await request.body()

      if not verify_signature(body, signature):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()

      asyncio.create_task(handle_webhook_event(event))

      return {"received": True}
  ```
</CodeGroup>

<Warning>
  Do not perform time-consuming operations (such as database writes or external API calls) before returning `200`. The Profy platform's delivery timeout is 10 seconds.
</Warning>

## Local Development and Debugging

During local development, the Webhook URL needs to be publicly accessible. Use a tunnel tool to expose your local port to the internet.

<CodeGroup>
  ```bash ngrok theme={null}
  ngrok http 3000
  ```

  ```bash cloudflared theme={null}
  cloudflared tunnel --url http://localhost:3000
  ```
</CodeGroup>

After obtaining the temporary public URL, enter it in the Profy Studio Webhook URL configuration (e.g., `https://abc123.ngrok-free.app/api/webhook`).

<Tip>
  During debugging, you can view the request/response details and retry history of each delivery in the Profy Studio Webhook logs.
</Tip>

## Complete Example

Here's a complete, ready-to-run example:

<CodeGroup>
  ```typescript server.ts theme={null}
  import express from "express";
  import { createHmac, timingSafeEqual } from "node:crypto";

  const app = express();
  const WEBHOOK_SECRET = process.env.PROFY_WEBHOOK_SECRET!;
  const processedEvents = new Map<string, number>();

  function verifySignature(body: Buffer, signature: string): boolean {
    if (!signature.startsWith("sha256=")) return false;

    const expected = createHmac("sha256", WEBHOOK_SECRET)
      .update(body)
      .digest("hex");

    const received = signature.slice("sha256=".length);

    if (expected.length !== received.length) return false;

    return timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(received, "hex")
    );
  }

  function isProcessed(eventId: string): boolean {
    return processedEvents.has(eventId);
  }

  function markProcessed(eventId: string): void {
    processedEvents.set(eventId, Date.now());
    if (processedEvents.size > 10_000) {
      const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
      for (const [id, ts] of processedEvents) {
        if (ts < cutoff) processedEvents.delete(id);
      }
    }
  }

  interface WebhookEvent {
    event_id: string;
    event_type: string;
    created_at: string;
    payload: Record<string, unknown>;
  }

  async function handleEvent(event: WebhookEvent) {
    if (isProcessed(event.event_id)) return;
    markProcessed(event.event_id);

    switch (event.event_type) {
      case "user.authorized":
        console.log("User authorized:", event.payload.user_id);
        break;
      case "user.revoked":
        console.log("User revoked:", event.payload.user_id);
        break;
      case "billing.event_processed":
        console.log("Billing processed:", event.payload);
        break;
      case "billing.insufficient_balance":
        console.log("Insufficient balance:", event.payload.user_id);
        break;
      default:
        console.warn("Unknown event:", event.event_type);
    }
  }

  app.post(
    "/api/webhook",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      const signature = req.headers["x-profy-signature"] as string;
      const body = req.body as Buffer;

      if (!verifySignature(body, signature)) {
        res.status(401).json({ error: "Invalid signature" });
        return;
      }

      const event: WebhookEvent = JSON.parse(body.toString());
      res.status(200).json({ received: true });

      handleEvent(event).catch((err) => {
        console.error(`Event ${event.event_id} failed:`, err);
      });
    }
  );

  app.listen(3000, () => {
    console.log("Webhook receiver running on :3000");
  });
  ```

  ```python server.py (FastAPI) theme={null}
  import hashlib
  import hmac
  import os
  import time
  from typing import Any

  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()

  WEBHOOK_SECRET = os.environ["PROFY_WEBHOOK_SECRET"]
  processed_events: dict[str, float] = {}


  def verify_signature(body: bytes, signature: str) -> bool:
      if not signature.startswith("sha256="):
          return False

      expected = hmac.new(
          WEBHOOK_SECRET.encode(), body, hashlib.sha256
      ).hexdigest()
      received = signature[len("sha256="):]

      return hmac.compare_digest(expected, received)


  def is_processed(event_id: str) -> bool:
      return event_id in processed_events


  def mark_processed(event_id: str) -> None:
      processed_events[event_id] = time.time()
      if len(processed_events) > 10_000:
          cutoff = time.time() - 7 * 24 * 60 * 60
          to_remove = [eid for eid, ts in processed_events.items() if ts < cutoff]
          for eid in to_remove:
              del processed_events[eid]


  async def handle_event(event: dict[str, Any]) -> None:
      if is_processed(event["event_id"]):
          return
      mark_processed(event["event_id"])

      match event["event_type"]:
          case "user.authorized":
              print(f"User authorized: {event['payload']['user_id']}")
          case "user.revoked":
              print(f"User revoked: {event['payload']['user_id']}")
          case "billing.event_processed":
              print(f"Billing processed: {event['payload']}")
          case "billing.insufficient_balance":
              print(f"Insufficient balance: {event['payload']['user_id']}")
          case _:
              print(f"Unknown event: {event['event_type']}")


  @app.post("/api/webhook")
  async def webhook(request: Request):
      signature = request.headers.get("x-profy-signature", "")
      body = await request.body()

      if not verify_signature(body, signature):
          raise HTTPException(status_code=401, detail="Invalid signature")

      import json
      event = json.loads(body)

      await handle_event(event)

      return {"received": True}
  ```
</CodeGroup>

## Security Best Practices

<CardGroup cols={2}>
  <Card title="Always Verify Signatures" icon="shield-check">
    Every request must pass HMAC-SHA256 signature verification — reject all unsigned or incorrectly signed requests
  </Card>

  <Card title="Use HTTPS" icon="lock">
    The Webhook URL must use HTTPS to prevent request content from being intercepted or tampered with in transit
  </Card>

  <Card title="Idempotent Processing" icon="rotate">
    Use `event_id` for deduplication to ensure the same event is only processed once even if delivered multiple times
  </Card>

  <Card title="Timeout Protection" icon="clock">
    Return 200 within 10 seconds. Move time-consuming logic to a background queue to avoid blocking responses and triggering retries
  </Card>
</CardGroup>

Additional recommendations:

* **IP Allowlisting**: If your firewall supports it, only allow traffic from Profy platform egress IP ranges
* **Secret Rotation**: Periodically regenerate the Webhook Secret in Studio and smoothly transition on the application side (support verifying both old and new Secrets during the transition period)
* **Logging and Monitoring**: Log the `event_id`, processing result, and latency for each Webhook to help troubleshoot missed or delayed events

## Next Steps

<CardGroup cols={2}>
  <Card title="Next.js Full-Stack Integration" icon="react" href="/en/developers/cookbook/nextjs-fullstack">
    OAuth login + Token management + billing event reporting
  </Card>

  <Card title="Per-Use Billing SaaS" icon="coins" href="/en/developers/cookbook/per-use-billing">
    PER\_USE mode: fixed-price per-use billing
  </Card>

  <Card title="Events API Reference" icon="book" href="/en/api/events">
    View the complete Events API documentation
  </Card>

  <Card title="Token Management Best Practices" icon="shield-check" href="/en/developers/cookbook/token-management">
    Concurrent refresh, secure storage, degradation strategies
  </Card>
</CardGroup>
