Webhooks

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

Last updated

View as Markdown

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.

curl https://api.mobilevalidate.com/v1/webhook_endpoints \
  -H "Authorization: Bearer mv_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/mobilevalidate","events":["lookup.completed","job.completed","job.failed"]}'

Needs your own key: replace mv_test_YOUR_KEY (get a test key), or omit it in the SDKs to use MOBILEVALIDATE_API_KEY.

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?

EventWhenStatus
lookup.completedA lookup that returned pending has finisheddelivered
job.completedA bulk job finisheddelivered (live jobs; test jobs finish at once and send none)
job.failedA bulk job failedsubscribable; delivery rolling out
job.progressProgress update for a running job (opt-in, at most once a minute)subscribable; delivery rolling out
balance.lowYour balance dropped below the low-balance thresholdsubscribable; delivery rolling out
limits.cap_reachedA daily or spend cap was reachedsubscribable; delivery rolling out
webhook.verification, webhook.testOwnership challenge and test deliveriesalways 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:

CounterCountsIn the example
totalrows (numbers and e-mails in the job)3 numbers
checks_totalchecks in the job (each row × the checks for its input type)3 × 3 = 9
donechecks finished9
conclusivechecks with a conclusive answer (billed)6
non_billablechecks that were not billed: unknown, unsupported, invalid, duplicate, cached9 − 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 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.