WWWveilig 2FA API · https://api.wwwveilig.nl

Integrating your application with the WWWveilig 2FA API

Base URL: https://api.wwwveilig.nl Auth: Authorization: Bearer wvk_<keyId>_<secret> on every call.

Get a key on the server:

cd /var/www/wwwveilig/api.wwwveilig.nl
php artisan twofa:app:create "My application" --callback=https://myapp.example/2fa/webhook

The command prints the API key and the webhook signing secret once. Store both in your application's .env; the key is never recoverable.


The model in one paragraph

Your application already knows who its users are. It keeps its own user ids and sends one of them (external_user_id) with every call. The 2FA API maps that id to an enrollment: a TOTP secret plus, once the phone has scanned the QR, a paired device that can receive push notifications. Nothing about your users is stored here beyond that id and an optional display label.


Step 1 - enroll a user (once)

POST /v1/enrollments
Authorization: Bearer wvk_...
Content-Type: application/json

{ "external_user_id": "42", "label": "jeroen@evweb.nl" }
{
  "data": {
    "id": "0cd4b2f4-7d47-4f61-99a7-4dbb399711fe",
    "status": "pending",
    "pairing_code": "A1B2C3D4",
    "pairing_uri": "wwwveilig://enroll?code=A1B2C3D4&api=https%3A%2F%2Fapi.wwwveilig.nl&app=My%20application&label=jeroen%40evweb.nl",
    "otpauth_uri": "otpauth://totp/WWWveilig%20%C2%B7%20My%20application:jeroen%40evweb.nl?secret=BZ3M...&digits=6&period=30",
    "secret": "BZ3MFAMSH4UVXE7RWECV3H4BP2B6S7QF",
    "expires_at": "2026-08-21T20:25:00+00:00"
  }
}

Render pairing_uri as a QR code on your "set up two-factor authentication" page. When the WWWveilig app scans it, the phone is bound to this user and push approvals become available.

otpauth_uri is the fallback for users who want Google Authenticator, 1Password or Bitwarden instead. Those give 6-digit codes but no push approvals - such an enrollment stays pending until the first correct code is verified.

Store data.id next to your user record. secret is only returned here for QR rendering; you never need to keep it.

The pairing code expires after 10 minutes (TWOFA_ENROLLMENT_TTL). Call the endpoint again with "force": true to issue a fresh one.

Check whether setup finished:

GET /v1/enrollments/{id}

status becomes active and device is filled in once the phone has paired.

The app's language (optional)

The WWWveilig app speaks English and Dutch. Send your user's choice, "en" or "nl", as language when you enroll, and again whenever they change it:

POST /v1/enrollments
{ "external_user_id": "42", "label": "jeroen@evweb.nl", "language": "nl" }

PATCH /v1/enrollments/{id}
{ "language": "en" }          (null clears it)

A re-enrollment without language keeps the earlier choice. The app switches at its next sync. A phone can hold accounts of several applications, so it shows the language of the account whose choice is the newest; with none set, it follows the phone. Write title and message of your push requests in the same language: the app shows them as you send them.


Step 2a - login with a 6-digit code

After the password step, ask for the code and verify it:

POST /v1/verify/totp
{ "external_user_id": "42", "code": "123456" }
{ "valid": true, "enrollment_id": "0cd4b2f4-...", "verified_at": "..." }

Failures return HTTP 4xx with a reason:

reason meaning
invalid_code wrong code, or more than 30s out of step
code_already_used that code was already spent - replay blocked
not_enrolled this user has no enrollment
enrollment_revoked 2FA was switched off for this user
too_many_attempts rate limit hit (10 per minute per user)

Only treat "valid": true as success. Never fall back to "assume valid on error".


Step 2b - login with a push approval

POST /v1/auth-requests
{
  "external_user_id": "42",
  "title": "My application",
  "message": "Sign in from Amsterdam?",
  "location": "Amsterdam, NL",
  "client_ip": "84.22.1.9",
  "number_matching": true,
  "ttl": 120
}
{
  "data": {
    "id": "d55bce2a-37fa-42dc-bb36-b9fb791dc6a7",
    "status": "pending",
    "challenge_number": 44,
    "push_delivered": true,
    "expires_at": "2026-08-21T20:16:31+00:00"
  }
}

With number_matching the phone shows three numbers and the user must tap the one your login page displays (challenge_number). Show it prominently. This stops the "user taps approve on a push they did not start" attack; a wrong tap denies the request immediately.

Then wait for the answer, either way:

Poll (simplest). GET /v1/auth-requests/{id}?wait=25 blocks server-side and returns the moment the user taps, or after 25 seconds. Loop it from an AJAX endpoint until status is no longer pending.

Webhook (no polling). Pass callback_url on creation, or set a default one when you create the application. On approve/deny the API POSTs:

{
  "event": "auth_request.approved",
  "auth_request_id": "d55bce2a-...",
  "status": "approved",
  "external_user_id": "42",
  "enrollment_id": "0cd4b2f4-...",
  "responded_at": "2026-08-21T20:15:02+00:00",
  "metadata": null,
  "timestamp": "2026-08-21T20:15:02+00:00"
}

with headers X-WWWveilig-Event, X-WWWveilig-Timestamp and X-WWWveilig-Signature: sha256=<hmac>. The HMAC is over "{timestamp}.{rawBody}" keyed with your signing secret. Verify it before acting, and reject timestamps older than 5 minutes. Deliveries retry with backoff (5s, 15s, 60s, 300s) on any non-2xx response.

Final statuses: approved, denied, expired, cancelled.

push_delivered: false with a push_error means the phone could not be reached - fall back to asking for a 6-digit code instead of failing the login.


Which method when

6-digit code push approval
Works offline on the phone yes no
Works with Google Authenticator yes no
One tap for the user no yes
Phishing resistance low higher with number_matching

Offering both is the normal setup: push as the default, code as the fallback.


PHP example

docs/examples/WwwveiligClient.php is a dependency-free client. Typical login controller:

$twofa = new WwwveiligClient(getenv('WWWVEILIG_API_KEY'));

// after the password check
if (! $twofa->isEnrolled($user->id)) {
    return redirect('/settings/2fa/setup');
}

$request = $twofa->requestApproval($user->id, [
    'title'           => 'My application',
    'message'         => 'Sign in from '.$city.'?',
    'client_ip'       => $request->ip(),
    'location'        => $city,
    'number_matching' => true,
]);

session(['2fa_request' => $request['id']]);
// show $request['challenge_number'] on screen, then poll:

$result = $twofa->waitForApproval($request['id']);

if ($result['status'] === 'approved') {
    Auth::login($user);
}

Webhook receiver:

$raw = file_get_contents('php://input');

if (! WwwveiligClient::verifyWebhook(
    $raw,
    $_SERVER['HTTP_X_WWWVEILIG_SIGNATURE'] ?? '',
    $_SERVER['HTTP_X_WWWVEILIG_TIMESTAMP'] ?? '0',
    getenv('WWWVEILIG_SIGNING_SECRET'),
)) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
// mark the pending login for $event['external_user_id'] as $event['status']
http_response_code(200);

JavaScript / Node example

const API = 'https://api.wwwveilig.nl';
const headers = {
  Authorization: `Bearer ${process.env.WWWVEILIG_API_KEY}`,
  'Content-Type': 'application/json',
};

export async function verifyCode(userId, code) {
  const response = await fetch(`${API}/v1/verify/totp`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ external_user_id: String(userId), code }),
  });

  return (await response.json()).valid === true;
}

export async function pushApprove(userId) {
  const create = await fetch(`${API}/v1/auth-requests`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ external_user_id: String(userId), number_matching: true }),
  });
  const { data } = await create.json();

  // show data.challenge_number to the user, then long-poll
  while (true) {
    const poll = await fetch(`${API}/v1/auth-requests/${data.id}?wait=25`, { headers });
    const { data: state } = await poll.json();
    if (state.status !== 'pending') return state.status;
  }
}

Endpoint reference

Your application (Bearer wvk_...)

Method Path Purpose
GET /v1/health liveness, no auth
GET /v1/me which application this key belongs to
POST /v1/enrollments start pairing, returns the QR payload
GET /v1/enrollments list, filter with ?external_user_id= or ?status=
GET /v1/enrollments/{id} pairing state
PATCH /v1/enrollments/{id} set the app's language (en / nl / null)
DELETE /v1/enrollments/{id} switch 2FA off for a user
POST /v1/verify/totp check a 6-digit code
POST /v1/auth-requests send an approve/deny push
GET /v1/auth-requests/{id} status, ?wait=25 to long-poll
POST /v1/auth-requests/{id}/cancel abandon a request

The mobile app (Bearer wvd_...)

Method Path Purpose
POST /v1/device/enroll redeem a pairing code, returns the device token
POST /v1/device/heartbeat refresh the Expo push token
GET /v1/device/accounts every account on this phone
DELETE /v1/device/accounts/{id} user removes an account
GET /v1/device/auth-requests pending challenges, ?wait=20 to long-poll
POST /v1/device/auth-requests/{id}/respond approve or deny

Your application never uses the /v1/device/* routes - those belong to the phone.


Rate limits

Endpoint Limit
POST /v1/verify/totp 10/min per application + user
POST /v1/auth-requests 20/min per application + user
POST /v1/enrollments 60/min per application
POST /v1/device/enroll 10/min per IP
/v1/device/* 120/min per device

Exceeding a limit returns HTTP 429 with a Retry-After header.


Security notes