> ## 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.

# otp.verified Webhook — OTP Verification Notification

> The otp.verified webhook is sent by OTP.ID to your server when an OTP is verified. Verify the HMAC-SHA256 signature before trusting the payload.

The `otp.verified` webhook is an outbound HTTP request that OTP.ID sends **to your server** when an OTP transaction reaches the `verified` state. Unlike the API endpoints documented elsewhere, you do not call this — you receive it. Configure your webhook URL in the OTP.ID dashboard. Every incoming request carries a cryptographic signature you must validate before trusting the payload.

## Direction and Trigger

OTP.ID sends a `POST` request to your configured webhook URL whenever a transaction is verified — either by a successful call to `POST /v3/verify` or by an automatic token match in a `whatsapp_inbound` flow. The delivery is asynchronous: the webhook fires after OTP.ID records the verification and may arrive a few seconds after the `verified` status is set.

<Note>
  If no webhook URL is configured in your OTP.ID dashboard, this event is silently dropped. The webhook is a no-op until you register a URL.
</Note>

## Request from OTP.ID

OTP.ID sends the following request to your endpoint:

* **Method**: `POST`
* **Content-Type**: `application/json`

### Headers

| Header              | Type   | Description                                                           |
| ------------------- | ------ | --------------------------------------------------------------------- |
| `X-OTPID-Timestamp` | string | Unix timestamp (seconds) at which OTP.ID sent this request.           |
| `X-OTPID-Signature` | string | HMAC-SHA256 hex signature. Verify this before processing the payload. |

### Payload Fields

<ResponseField name="event" type="string">
  Always `"otp.verified"`. Use this field if your endpoint handles multiple event types.
</ResponseField>

<ResponseField name="otp_id" type="string">
  The verified transaction ID (e.g. `"OTP20260807ABCD000001"`).
</ResponseField>

<ResponseField name="channel" type="string">
  The delivery channel used for the transaction (e.g. `"whatsapp"`, `"sms"`, `"email"`).
</ResponseField>

<ResponseField name="number" type="string">
  The destination phone number or email address associated with the transaction.
</ResponseField>

<ResponseField name="verified_at" type="string">
  The datetime at which verification occurred, in `YYYY-MM-DD HH:MM:SS` format (WIB, UTC+7).
</ResponseField>

### Payload Example

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

<Note>
  The payload never contains the OTP code, any hash or salt of the code, or inbound tokens. It carries only the identity and outcome of the transaction.
</Note>

## Signature Verification

Every request from OTP.ID includes an `X-OTPID-Signature` header. Always verify this signature before acting on the payload — it protects you against spoofed requests.

**Algorithm (HMAC-SHA256):**

1. Read the `X-OTPID-Timestamp` header value.
2. Read the **raw, unparsed request body** as a string (before JSON parsing).
3. Concatenate: `"{timestamp}." + raw_body`
4. Compute `HMAC-SHA256` of that string using your `webhook_secret` (from the OTP.ID dashboard).
5. Compare the result (hex-encoded) with `X-OTPID-Signature` using a **constant-time comparison** to prevent timing attacks.

<Warning>
  Use the raw request body — not a serialized version of the parsed JSON — to compute the signature. JSON serialization can alter whitespace or key ordering, causing valid signatures to fail.
</Warning>

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

  app.post('/webhooks/otp', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.headers['x-otpid-timestamp'];
    const signature = req.headers['x-otpid-signature'];
    const rawBody = req.body.toString();

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

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

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

    const event = JSON.parse(rawBody);
    // Process event.otp_id, event.channel, event.verified_at ...
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  from flask import Flask, request

  app = Flask(__name__)

  @app.route('/webhooks/otp', methods=['POST'])
  def handle_webhook():
      timestamp = request.headers.get('X-OTPID-Timestamp')
      signature = request.headers.get('X-OTPID-Signature')
      raw_body = request.get_data(as_text=True)

      message = f"{timestamp}.{raw_body}"
      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          message.encode(),
          hashlib.sha256
      ).hexdigest()

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

      event = request.get_json(force=True)
      # Process event['otp_id'], event['channel'], event['verified_at'] ...
      return '', 200
  ```
</CodeGroup>

## Retry Policy

If your endpoint returns a non-2xx response or OTP.ID cannot reach it within the timeout, OTP.ID retries the delivery automatically:

| Attempt   | Delay before retry |
| --------- | ------------------ |
| 1st retry | 1 second           |
| 2nd retry | 2 seconds          |
| 3rd retry | 4 seconds          |

Each attempt has a **10-second timeout**. After 3 failed retries the event is dropped and not retried further.

<Warning>
  Respond with an HTTP 2xx status code as quickly as possible — within the 10-second timeout. If your processing logic is slow (database writes, downstream API calls, etc.), acknowledge receipt immediately and process the event asynchronously in a background job or queue.
</Warning>

## Checklist for Your Endpoint

* ✅ Parse the raw body **before** any middleware that mutates it
* ✅ Validate `X-OTPID-Signature` using constant-time comparison
* ✅ Optionally reject requests where `X-OTPID-Timestamp` is too far in the past (e.g. > 5 minutes) to guard against replay attacks
* ✅ Return HTTP 200 immediately; process async if needed
* ✅ Treat duplicate deliveries of the same `otp_id` idempotently — retries may cause the same event to arrive more than once
