# Webhooks

> Receive signed MobileValidate webhooks when lookups and jobs finish: endpoint verification, event types, Standard Webhooks signatures and retries.

Canonical: https://mobilevalidate.com/docs/webhooks · Last updated: 2026-09-25

Webhooks notify your server when a lookup or job finishes, so you don't have to poll. Every delivery is an HTTPS POST signed with the Standard Webhooks scheme, an HMAC-SHA256 over the message id, timestamp and body. Endpoints must prove they are yours before they receive events. Failed deliveries are retried for up to 24 hours.

## How do I register an endpoint?

Managing endpoints needs a key with the `webhooks:manage` scope. Keys don't get this scope by default, and the public sandbox key never has it (it answers `403 insufficient_scope`), so use your own key here.

```bash key=personal expect=403
curl https://api.mobilevalidate.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/mobilevalidate", "events": ["lookup.completed", "job.completed", "job.failed"]}'
```

The response (`201`) includes `id` (`we_…`), `status: "pending_verification"` and `secret` (`whsec_…`). **The secret is shown only this once.** Store it in your secret manager.

- The URL must be `https://` and resolve to a public IP address. Private, loopback and link-local addresses are refused, and redirects are not followed.
- Endpoints belong to a mode. An endpoint created with a test key is a test endpoint (`livemode: false`) and gets only test-mode events. One created with a live key is a live endpoint and gets only live events. Each key sees and manages only the endpoints of its own mode, so a test key can never change where your live events go.
- An account can have up to 20 endpoints per mode.
- `GET /v1/webhook_endpoints` lists them, `PATCH /v1/webhook_endpoints/{id}` changes `url` or `events` (a new URL must be verified again), and `DELETE` removes one.

## How does endpoint verification work?

Within about 30 seconds of creating an endpoint, we send it a signed `webhook.verification` event, and resend it every hour until it is answered. When your endpoint answers with any `2xx` status, it becomes `active`. `webhook_endpoint_id` in a request must name a verified endpoint of the same mode as the key (`400 invalid_request` otherwise). If you later change an endpoint's URL, its events are held, not dropped, until the new URL is verified (they expire after 24 hours). This ownership challenge stops anyone from pointing our deliveries at a server they don't control.

`POST /v1/webhook_endpoints/{id}/test` queues a signed `webhook.test` event (answers `202`). Use it to check your signature code. Test events are delivered even before verification.

To have a lookup or job notify a particular endpoint, pass its id as `webhook_endpoint_id` in `POST /v1/lookup` or `POST /v1/jobs`. You can't pass an ad-hoc URL.

## Which events are there?

| Event | When | Status |
|---|---|---|
| `lookup.completed` | A lookup that returned `pending` has finished | delivered |
| `job.completed` | A bulk job finished | delivered (live jobs; test jobs finish at once and send none) |
| `job.failed` | A bulk job failed | subscribable; delivery rolling out |
| `job.progress` | Progress update for a running job (opt-in, at most once a minute) | subscribable; delivery rolling out |
| `balance.low` | Your balance dropped below the low-balance threshold | subscribable; delivery rolling out |
| `limits.cap_reached` | A daily or spend cap was reached | subscribable; delivery rolling out |
| `webhook.verification`, `webhook.test` | Ownership challenge and test deliveries | always sent |

Lookup and job events go to the endpoint named in that request's `webhook_endpoint_id`, provided the endpoint subscribes to the event. Every payload has the shape `{"type", "id", "created_at", "data": {…}}`. Job events carry a summary and a results URL, never phone numbers or e-mail addresses:

```json
{"type": "job.completed", "id": "evt_…", "created_at": "2026-09-25T14:30:02Z",
 "data": {"object": "job", "id": "job_…", "status": "completed",
          "progress": {"total": 3, "checks_total": 9, "done": 9, "conclusive": 6, "non_billable": 3},
          "results_url": "https://api.mobilevalidate.com/v1/jobs/job_…/results"}}
```

This is a job of 3 numbers with 3 checks each. The `progress` counters use two units:

| Counter | Counts | In the example |
|---|---|---|
| `total` | **rows** (numbers and e-mails in the job) | 3 numbers |
| `checks_total` | **checks** in the job (each row × the checks for its input type) | 3 × 3 = 9 |
| `done` | **checks** finished | 9 |
| `conclusive` | checks with a conclusive answer (billed) | 6 |
| `non_billable` | checks that were not billed: unknown, unsupported, invalid, duplicate, cached | 9 − 6 = 3 |

So `done` can be larger than `total`: compare it with `checks_total`, which it reaches when the job is finished. `GET /v1/jobs/{id}` returns the same counters.

A `lookup.completed` event carries a summary in the same units: `total` counts rows, the other counters count checks. Test keys send the same fields with `"livemode": false`:

```json
{"type": "lookup.completed", "id": "evt_…", "created_at": "2026-09-25T14:30:05Z",
 "data": {"object": "lookup", "id": "lkp_…", "status": "completed", "livemode": true,
          "summary": {"total": 1, "registered": 1, "not_registered": 0, "unknown": 0}}}
``` Fetch the rows with your key. Treat `id` as the idempotency key for your handler, because retries can deliver the same event more than once.

## How do I verify the signature?

Each delivery has three headers:

| Header | Content |
|---|---|
| `webhook-id` | The event id (same across retries) |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` of `${webhook-id}.${webhook-timestamp}.${raw body}` |

The HMAC key is the base64-decoded part of the secret after `whsec_`. Use the **raw** request body, not re-serialised JSON. Reject timestamps more than 5 minutes from your clock, and compare signatures in constant time:

```js title="verify-webhook.mjs (Node.js 18+, ES module)"
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"] ?? "";
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale or missing headers");
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
  const ok = sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    const got = Buffer.from(sig ?? "", "base64");
    return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
  if (!ok) throw new Error("invalid signature");
  return JSON.parse(rawBody);
}
```

The [SDK](/docs/sdk) ships the same check as `verifyWebhook` from `mobilevalidate/webhooks`. Any Standard Webhooks library works too.

## What happens when a delivery fails?

Anything other than a `2xx` within 10 seconds counts as a failure. That includes timeouts, connection errors and redirects. We retry after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours. If the next retry would fall more than 24 hours after the event, the delivery is marked failed instead. The `webhook-id` stays the same across retries, and `webhook-timestamp` and the signature are new for each attempt. Answer quickly with `2xx`, then do the heavy work in the background. Responses larger than 64 KB are cut off, and their content is ignored.

## Frequently asked questions

### Why is my endpoint not receiving job events?

New endpoints stay in pending_verification until they answer our signed webhook.verification event with a 2xx status. Until then, other events are held. Check that your endpoint returns 2xx quickly and is reachable over public HTTPS.

### Do webhook payloads contain phone numbers?

No. Job events carry a summary and a results URL; fetch the rows with your API key.

### I lost my signing secret. Can I see it again?

No. The secret is shown once when the endpoint is created. Delete the endpoint and create a new one.
