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
- Keep the API key server-side. It can enroll and verify for any of your users.
- Every TOTP code can be spent only once; a replayed code returns
code_already_usedeven while it is still in its 30-second window. - Secrets are stored encrypted with the API's
APP_KEY. Rotating that key invalidates every enrollment, so back it up together with the database. - Verify webhook signatures. An unsigned POST to your callback is not proof of anything.
- Rotate a leaked key with
php artisan twofa:app:rotate <slug>; the old key stops working immediately.