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

# Webhooks

> Receive status updates for verifications, SMS and Telegram codes at your own URL.

Instead of polling, give OTPBay a `callback` URL. When the status of a verification, SMS or Telegram code changes, OTPBay sends a `POST` request to that URL with the new status.

## Set a callback URL

Pass `callback` when you send:

```json theme={null}
{
  "to": "+14155552671",
  "callback": "https://example.com/webhooks/otpbay"
}
```

Or set a **default callback URL** for each channel in the dashboard: **Messaging → Verify**, **SMS** or **Telegram**. A `callback` in the request overrides the default.

The URL must use `http` or `https` and point at a public host. `localhost`, private network, link-local and cloud metadata addresses are rejected with `422 INVALID_REQUEST_BODY`. To test locally, use a tunnel such as ngrok.

## When webhooks are sent

| `channel`  | Sent when `status` becomes                                                            |
| ---------- | ------------------------------------------------------------------------------------- |
| `verify`   | `approved`, `expired` or `failed` — once per verification, after refunds are settled. |
| `sms`      | `sent` or `failed`.                                                                   |
| `telegram` | `sent`, `delivered`, `read`, `expired`, `revoked` or `failed` — on every change.      |

<Note>
  Verify sends only the `verify` webhook. The Telegram and SMS messages it sends on your behalf don't send their own webhooks.
</Note>

Telegram doesn't push status changes, so OTPBay checks for them about 5, 15, 30, 60, 120 and 300 seconds after sending, and once more just after the code expires. A `delivered` webhook can arrive a little after the actual delivery.

## Payload

Every channel sends the same JSON body:

```json theme={null}
{
  "sid": "VE6650c3a1b2c3d4e5f6a7b8c9",
  "channel": "verify",
  "status": "approved",
  "to": "+14155552671",
  "cost": "0.04",
  "metadata": { "user_id": "usr_1042" },
  "error": null,
  "date_created": "2026-09-26T10:15:01.873Z",
  "date_updated": "2026-09-26T10:15:40.530Z"
}
```

<ResponseField name="sid" type="string">
  ID of the verification (`VE…`), SMS (`SM…`) or Telegram code (`TG…`).
</ResponseField>

<ResponseField name="channel" type="string">
  `verify`, `sms` or `telegram`.
</ResponseField>

<ResponseField name="status" type="string">
  The new status.
</ResponseField>

<ResponseField name="to" type="string">
  Destination phone number in E.164 format.
</ResponseField>

<ResponseField name="cost" type="string">
  Current cost in US dollars, after refunds.
</ResponseField>

<ResponseField name="metadata" type="object">
  The `metadata` you sent with the request.
</ResponseField>

<ResponseField name="error" type="string | null">
  The error message when the status is a failure, otherwise `null`.
</ResponseField>

<ResponseField name="date_created" type="string">
  When the object was created (ISO 8601).
</ResponseField>

<ResponseField name="date_updated" type="string">
  When the object last changed (ISO 8601).
</ResponseField>

The request has the headers `Content-Type: application/json` and `User-Agent: Otpbay-Webhook/1`.

## Respond to webhooks

Return any `2xx` status within 15 seconds. The response body is ignored.

<Warning>
  OTPBay delivers each webhook once and doesn't retry. If your endpoint is down, [fetch the object](/api-reference/verify/get) by `sid` to get its latest status.
</Warning>

## Handle webhooks safely

* **Treat webhooks as hints.** Webhooks aren't signed. Before acting on one — for example, marking a phone number verified — fetch the object from the API with its `sid` and check the status there.
* **Use a hard-to-guess URL.** Add a secret to the path, such as `/webhooks/otpbay/4f9c2a...`, and reject requests to any other path.
* **Expect duplicates and any order.** Key your processing on `sid` and `status`, and ignore updates you've already handled.
* **Respond fast.** Acknowledge right away and do slow work in a background job.

```javascript Express theme={null}
app.post("/webhooks/otpbay/:secret", express.json(), async (req, res) => {
  if (req.params.secret !== process.env.OTPBAY_WEBHOOK_SECRET) {
    return res.sendStatus(404);
  }
  res.sendStatus(200);

  const { sid, channel } = req.body;
  if (channel !== "verify") return;

  // Confirm with the API before trusting the payload.
  const r = await fetch(`https://api.otpbay.com/v1/verifications/${sid}`, {
    headers: { Authorization: `Bearer ${process.env.OTPBAY_API_KEY}` },
  });
  const verification = await r.json();
  await handleVerification(verification);
});
```

See the [status webhook reference](/api-reference/webhooks/status) for the full schema.
