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

# Webhook Notifications for OTP Verification | OTP.ID

> Configure a webhook URL to receive real-time notifications when OTP.ID verifies a transaction. Includes HMAC signature verification.

Instead of polling the OTP.ID API to find out when a user has been verified, you can configure a webhook endpoint and receive a real-time push notification the moment verification succeeds. OTP.ID sends an HTTP POST to your URL immediately after a successful `POST /v3/verify` call or an automatic WhatsApp Inbound match. This makes webhooks especially important for [WhatsApp Inbound](/guides/whatsapp-inbound) flows, where your server is never part of the verification exchange.

## Setup

Log in to the OTP.ID dashboard, navigate to **Settings → Webhooks**, and enter your endpoint URL. OTP.ID will begin delivering `otp.verified` events to that URL for all verified transactions on your account.

## When the webhook fires

The `otp.verified` event is triggered in two situations:

* A user submits the correct code to `POST /v3/verify` and the API returns `"verified": true`.
* A user sends the correct WhatsApp Inbound message and OTP.ID matches the token automatically.

## Delivery behaviour

OTP.ID delivers webhooks asynchronously — the HTTP POST to your endpoint happens in the background and does not block the API response your server receives. If delivery fails, OTP.ID retries according to the following policy:

| Attempt       | Delay before attempt |
| ------------- | -------------------- |
| 1st (initial) | Immediate            |
| 2nd           | 1 second             |
| 3rd           | 2 seconds            |
| 4th           | 4 seconds            |

Each attempt has a **10-second timeout**. Any non-2xx HTTP response or network-level failure (connection refused, DNS error, timeout) counts as a failure and triggers the next retry. After 3 retries without success, the event is abandoned.

## Webhook payload

OTP.ID sends a JSON body with the following structure. The payload intentionally excludes the OTP code, any hash of it, or the raw verification token — only the metadata needed to identify and act on the event is included.

```json theme={null}
{
  "event": "otp.verified",
  "otp_id": "OTP20260807ABCD000001",
  "channel": "whatsapp",
  "number": "6281234567890",
  "verified_at": "2026-08-07 10:01:30"
}
```

| Field         | Description                                                       |
| ------------- | ----------------------------------------------------------------- |
| `event`       | Always `"otp.verified"`                                           |
| `otp_id`      | The transaction ID that was verified                              |
| `channel`     | The channel used to deliver (or receive) the OTP                  |
| `number`      | The phone number or email address associated with the transaction |
| `verified_at` | UTC datetime of verification                                      |

<Note>
  The webhook payload **never** contains the OTP code itself, any hash of it,
  or the WhatsApp Inbound token. Store only what you need from the payload; do
  not log the full request body if your logs are broadly accessible.
</Note>

## Request headers

Every webhook request includes two security headers you must validate:

| Header              | Description                                              |
| ------------------- | -------------------------------------------------------- |
| `X-OTPID-Timestamp` | Unix timestamp (seconds) of when OTP.ID sent the request |
| `X-OTPID-Signature` | HMAC-SHA256 hex digest of the signed message             |

## Signature verification

<Warning>
  **Always verify the signature before processing a webhook event.** Without
  this check, any party that discovers your webhook URL can send forged events
  and trigger actions in your system — for example, granting access to a user
  who never actually verified their number.
</Warning>

OTP.ID signs each request so you can confirm it originated from OTP.ID and has not been tampered with. The signed message is constructed by concatenating the timestamp, a period, and the raw JSON body:

```
signed_message = "{X-OTPID-Timestamp}.{raw_body_json}"
```

Compute the expected HMAC-SHA256 using your `webhook_secret` (found in the OTP.ID dashboard under **Settings → Webhooks**) and compare it to `X-OTPID-Signature` using a **constant-time comparison** to prevent timing attacks.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(req, webhookSecret) {
    const timestamp = req.headers['x-otpid-timestamp'];
    const signature = req.headers['x-otpid-signature'];
    const rawBody = req.body; // raw string, not parsed JSON

    const message = `${timestamp}.${rawBody}`;
    const expected = crypto
      .createHmac('sha256', webhookSecret)
      .update(message)
      .digest('hex');

    // Use timingSafeEqual to prevent timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature)
    );
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook(timestamp: str, signature: str, raw_body: str, webhook_secret: str) -> bool:
      message = f"{timestamp}.{raw_body}"
      expected = hmac.new(
          webhook_secret.encode(),
          message.encode(),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

<Note>
  Make sure you compute the HMAC over the **raw request body string**, not a
  re-serialised version of the parsed JSON. JSON serialisers may reorder keys
  or change whitespace, which would produce a different digest and cause every
  signature check to fail.
</Note>

## Complete webhook handler example

The following examples show a minimal but secure webhook handler that verifies the signature before doing anything else.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  const express = require('express');
  const crypto = require('crypto');

  const app = express();

  // Use raw body middleware so you receive the original bytes
  app.use('/webhook/otp', express.raw({ type: 'application/json' }));

  app.post('/webhook/otp', (req, res) => {
    const timestamp = req.headers['x-otpid-timestamp'];
    const signature = req.headers['x-otpid-signature'];
    const rawBody = req.body.toString('utf8');

    // 1. Verify the signature immediately — reject anything that doesn't match
    const message = `${timestamp}.${rawBody}`;
    const expected = crypto
      .createHmac('sha256', process.env.OTPID_WEBHOOK_SECRET)
      .update(message)
      .digest('hex');

    const isValid = crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature ?? '')
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // 2. Acknowledge immediately — do not wait for your business logic
    res.status(200).send('OK');

    // 3. Process the event asynchronously
    const event = JSON.parse(rawBody);
    setImmediate(() => handleOtpVerified(event));
  });

  async function handleOtpVerified(event) {
    if (event.event !== 'otp.verified') return;
    // e.g. mark the session as authenticated, grant access, send a welcome message
    console.log(`Transaction ${event.otp_id} verified at ${event.verified_at}`);
  }
  ```

  ```python Python (FastAPI) theme={null}
  import hmac
  import hashlib
  import asyncio
  from fastapi import FastAPI, Request, Response

  app = FastAPI()
  WEBHOOK_SECRET = "your_webhook_secret_here"

  @app.post("/webhook/otp")
  async def receive_webhook(request: Request):
      timestamp = request.headers.get("x-otpid-timestamp", "")
      signature = request.headers.get("x-otpid-signature", "")
      raw_body = (await request.body()).decode("utf-8")

      # 1. Verify the signature immediately
      message = f"{timestamp}.{raw_body}"
      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          message.encode(),
          hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(expected, signature):
          return Response(content="Invalid signature", status_code=401)

      # 2. Acknowledge immediately
      import json
      event = json.loads(raw_body)

      # 3. Schedule async processing (fire-and-forget)
      asyncio.create_task(handle_otp_verified(event))

      return Response(content="OK", status_code=200)

  async def handle_otp_verified(event: dict):
      if event.get("event") != "otp.verified":
          return
      print(f"Transaction {event['otp_id']} verified at {event['verified_at']}")
  ```
</CodeGroup>

## Best practices

**Acknowledge quickly, process asynchronously.** Your endpoint has 10 seconds to respond before OTP.ID considers the attempt failed and schedules a retry. Return `200 OK` as soon as the signature is validated, then process the event in the background. This keeps your handler fast and prevents spurious retries caused by slow database writes or downstream API calls.

**Make your handler idempotent.** OTP.ID may deliver the same event more than once if a retry fires after your first `200` was sent but not received. Use `otp_id` as a deduplication key — check whether you've already processed that transaction before taking action.

**Reject invalid signatures with HTTP 401.** Do not return `200` for requests that fail signature validation. A `200` response tells OTP.ID the event was received successfully and no retry is needed, which would suppress the legitimate delivery.

**Keep your `webhook_secret` out of source code.** Load it from an environment variable or a secrets manager. Rotate it in the OTP.ID dashboard if you suspect it has been compromised.
