API reference
Every endpoint in this document is callable today against https://winkkey.net. API keys are multi-tenant: each key is bound to a specific Wink env at issue time (dev / qa / stage / prod) so a single integration that needs to switch envs holds N keys and picks per-call. Keys can also carry narrow scopes (signing-key:read) for bundling in mobile binaries without granting broad operator power.
Authentication
All operator endpoints require an Authorization: Bearer <key> header. Two key types are accepted today:
- RP session JWT — minted when an operator signs in to /admin with WinkKey or Wink. Browser flow.
- Server-to-server API key —
wkey_…bearer issued via /admin → Merchants → Issue API key, or via the self-service invite-code request flow. Bound to one Wink env; optionally scoped.
For per-user audit when calling from a multi-user backend, also forward X-WinkKey-On-Behalf-Of: user@example.com. The originating user’s email shows up in the admin "Created by" column.
On this page
/api/admin/api-keys/requestRequest an API key (no auth)
Self-service entry point for new entities to onboard against the WinkKey merchant API. Mints a key immediately and returns the raw value once. The key is inactive until an admin approves the request from the dashboard. Approval activates the same key value retroactively — no second issuance, no second copy/paste.
inviteCodestringrequired | One-time invite code issued by a WinkKey admin (format WINKKEY-XXXX-XXXX-XXXX). Each code redeems for exactly one key. Reject reasons: invite_code_invalid, invite_code_revoked, invite_code_already_used. |
labelstringrequired | 1-64 chars, alphanumeric + ._- (no spaces). Used as the audit prefix on every link this key creates: api:<label>. |
requestedByEmailstringrequired | Recorded as createdByEmail. Not email-verified by us; admin still gates approval. |
reasonstringoptional | Short note (≤280 chars) shown to the admin alongside the pending row. Helps the operator decide whether to approve. |
curl -X POST https://winkkey.net/api/admin/api-keys/request \
-H "Content-Type: application/json" \
-d '{
"inviteCode": "WINKKEY-XXXX-XXXX-XXXX",
"label": "acme-saml",
"requestedByEmail": "ops@acme.example",
"reason": "SAML IdP onboarding for federated sign-in pilot."
}'{
"id": "ZmFrZTEyMzQ",
"label": "acme-saml",
"keyPrefix": "wkey_a3F2bX…",
"status": "pending_approval",
"rawKey": "wkey_a3F2bXc4Z…",
"createdAt": 1730000000000,
"requestedByEmail": "ops@acme.example",
"warning": "Save this key now — it will not be shown again. The key is INACTIVE until an admin approves the request."
}/api/merchant/auth-requestCreate a link
Generates a one-time link for either auth (verify existing user) or enrol (onboard new user). Single endpoint, kind flag drives behaviour.
kind"auth" \| "enroll"optional | Default "auth". Use "enroll" to onboard a new user — server forces forceWinkCloud=true and bumps default TTL to 2 days. |
ttlSecondsnumberoptional | Link lifetime. Default 900 (auth) / 172800 (enrol). Capped at 604800 (7 days). |
userHintEmailstringoptional | Auth only — the link will only consume if the verifying user's email matches. Mismatch returns a structured 403; consumer can cancel. Ignored for kind="enroll". |
callbackUrlstringoptional | If set, the verify page redirects here with ?token=<JWT> after consume. If null, the user lands on a success screen. |
forceWinkCloudbooleanoptional | Auth only — hides the WinkKey passkey button on the verify page; forces a fresh Wink face match. For high-assurance step-up. Always true for kind="enroll". |
displayTitlestringoptional | Custom page title (≤ 80 chars). Defaults: "Verify your identity" (auth) / "Create your Wink account" (enrol). |
displaySubtextstringoptional | Custom subheading (≤ 280 chars). Defaults are kind-aware and substitute the merchant name. |
curl -X POST https://winkkey.net/api/merchant/auth-request \
-H "Authorization: Bearer wkey_…" \
-H "Content-Type: application/json" \
-d '{
"kind": "auth",
"ttlSeconds": 900,
"userHintEmail": "user@example.com",
"callbackUrl": "https://acme.com/cb"
}'{
"rid": "Abc...",
"kind": "auth",
"url": "https://winkkey.net/v2/stage/auth/Abc...",
"expiresAt": 1745800000000
}env field in the request body. The link inherits the env from your API key (set at issue time — see admin → Issue key). The bound env stamps auth_requests.env server-side, which:- Surfaces in the returned
urlas a path segment (/v2/<env>/auth/<rid>) — observable in logs, history, share targets. - Scopes the WebAuthn
allowCredentialslist on consume so iOS Keychain only offers passkeys registered against the same env. - Decides which Wink Cloud realm the user's face-cap fallback flow talks to.
To address multiple envs from one integration: hold one key per env, pick the right key at call time. Or — if you're building a first-party UI that should mirror an operator's active-env selector — request a key minted with env = null ("follow active env") and it resolves dynamically.
/api/merchant/auth-request/:ridPoll for completion
Server-to-server safe. Returns full link state. When status is consumed AND the user had a Wink session at consume time, the response also includes a fresh Wink access token captured server-side. Repeat polls return the same snapshot — no refresh races.
curl https://winkkey.net/api/merchant/auth-request/Abc...{
"rid": "Abc...",
"merchantId": "winkkey",
"env": "stage",
"status": "consumed",
"kind": "auth",
"createdAt": 1745700000000,
"expiresAt": 1745700900000,
"consumedAt": 1745700123000,
"consumedBy": {
"userId": "...",
"winkTag": ";stage-...",
"email": "user@example.com"
},
"consumedMethod": "winkkey",
"resultToken": "eyJ...",
"winkAccessToken": "eyJ...",
"winkAccessTokenExpiresAt": 1745700420000,
"winkAccessTokenStatus": "fresh",
"winkTokenAvailable": true,
"callbackUrl": "https://acme.com/cb",
"userHintEmail": null,
"forceWinkCloud": false,
"displayTitle": null,
"displaySubtext": null,
"createdBy": { "userId": "...", "email": "operator@you.com" }
}pending, consumed, expired, failed (cancelled). consumedMethod is "winkkey" (passkey) or "wink_cloud" (face match).fresh while within its ~5 min Wink TTL, stale after. Stale-but-present tokens are still returned for audit; merchants needing a usable token should issue a new auth-link.winkAccessToken fields entirely; flagged via winkTokenAvailable: false. The merchant treats resultToken as identity-only./api/merchant/auth-request/:ridCancel a pending link
Marks the link failed server-side. Once cancelled, the user sees an 'auth link was cancelled' message if they tap. Idempotent — calling on an already-consumed or cancelled link is a no-op.
curl -X DELETE https://winkkey.net/api/merchant/auth-request/Abc... \
-H "Authorization: Bearer wkey_…"{ "ok": true, "rid": "Abc...", "status": "failed" }/api/merchant/auth-request/:rid?action=renewRenew an expired or cancelled link
Same rid, fresh TTL (default +15 min, override via body). Reactivates a link that expired or was cancelled. Refuses to renew a consumed link — use Repeat (a fresh create with the same params) instead.
curl -X POST "https://winkkey.net/api/merchant/auth-request/Abc...?action=renew" \
-H "Authorization: Bearer wkey_…" \
-H "Content-Type: application/json" \
-d '{ "ttlSeconds": 900 }'{ "ok": true, "rid": "Abc...", "expiresAt": 1745800000000, "status": "pending" }/api/merchant/auth-request/:rid/send-emailEmail the link
Sends the link via SendGrid to the provided address (or row.userHintEmail if unset). Branded WinkKey template, soft-language subject + body tuned for inbox placement.
curl -X POST https://winkkey.net/api/merchant/auth-request/Abc.../send-email \
-H "Authorization: Bearer wkey_…" \
-H "Content-Type: application/json" \
-d '{ "to": "user@example.com" }'{ "ok": true, "sentTo": "user@example.com" }/api/auth/:ridPublic metadata (open)
No auth required. Returns the minimum needed to render the verify page. Used internally by the /auth/:rid React app and the iOS native handler.
{
"rid": "Abc...",
"merchantId": "winkkey",
"env": "stage",
"status": "pending",
"expiresAt": 1745800000000,
"userHintEmail": null,
"hasCallback": true,
"userHasWinkKey": null,
"forceWinkCloud": false,
"kind": "auth",
"displayTitle": null,
"displaySubtext": null
}userHintEmail is set and resolves to a registered user, this reflects whether they have any passkey on file (true / false). null means no hint; the verify page shows both options./api/wink/signing-keyWink SDK signing key (scoped)
Returns the Wink-SDK signing PEM + keyId + merchantId for the env the caller's API key is bound to. Designed for mobile clients (iOS, Android) and future SDK customers to fetch their Wink-Cloud credentials at runtime instead of bundling the PEM in the binary. Bearer-authenticated with a scoped API key (signing-key:read). PEM is envelope-encrypted at rest; decrypted only inside this handler.
curl https://winkkey.net/v2/api/wink/signing-key \
-H "Authorization: Bearer wkey_..."{
"env": "stage",
"keyId": "23c96f21-7c0e-4a25-a4ba-8b05ab1e86ac",
"merchantId": "winkkey",
"pem": "-----BEGIN PRIVATE KEY-----\n..."
}signing-key:readcapability. Issued via admin → Merchants → Issue API key with the scope checkbox. Narrow keys can't pivot to the broad merchant API surface, so extracted-from-IPA exposure is bounded to "read the env's PEM."env=null (first-party mode) follow the admin's active-env selector — useful for first-party mobile apps that should mirror the operator's env choice without rebuilding. Keys with a pinned env (third-party callers) always resolve to that env per A3.Cache-Control: no-store. Clients should cache the PEM locally (encrypted Keychain on iOS) and revalidate on env switch or on a configurable cadence. WinkKey iOS reference implementation re-fetches on every launch (~300ms typical), falling back to on-disk cache when offline.window.postMessageEmbed events
When you embed /v2/<env>/auth/<rid> in a popup or iframe, the verify page posts terminal events to window.opener and window.parent on completion. Filter by event.origin === 'https://winkkey.net' on your side.
window.addEventListener('message', (e) => {
if (e.origin !== 'https://winkkey.net') return;
if (e.data?.type !== 'winkkey') return;
switch (e.data.event) {
case 'auth-link:done':
// e.data.token — the result JWT
// e.data.callbackUrl — null if none was set
break;
case 'auth-link:cancelled':
// user explicitly cancelled (after user-mismatch, etc.)
break;
case 'auth-link:mismatch':
// verifier signed in as the wrong user
// e.data.expectedEmail / e.data.actualEmail
break;
case 'auth-link:invalid':
// expired, malformed, or generic error
// e.data.message has detail
break;
}
});winkAccessToken deliberately is not shipped via postMessage — short-lived bearer credentials shouldn’t propagate through opener channels. Stick to the server-side poll for that./api/merchant/vc/issueIssue a Verifiable Credential
Exchange a still-valid Wink access token or a WinkKey result token for a W3C Verifiable Credential (JWT-VC form) attesting that the named user authenticated via WinkKey. Hard invariant: every issued VC corresponds to a moment when Wink had a valid access token. Side-channel — not on the auth critical path.
Supply exactly one of winkAccessToken or resultToken. Same bearer auth as the rest of the merchant API.
winkAccessTokenstringoptional | A Wink-issued access token. Validated via Wink userinfo. We do not refresh tokens we didn't issue — expired tokens get a 400. |
resultTokenstringoptional | A WinkKey result token from auth-link consume. We use the snapshot captured at consume time, refreshing once via the vault if stale. |
audiencestringoptional | Optional VC audience. When set, verifiers SHOULD reject the VC unless presented to this audience. |
ttlSecondsintegeroptional | VC validity window in seconds (default 300, max 3600). |
curl -X POST https://winkkey.net/api/merchant/vc/issue \
-H "Authorization: Bearer $WINKKEY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"resultToken": "<jwt-from-auth-link-consume>"
}'{
"vc": "eyJhbGciOiJFZERTQSIs...",
"format": "vc-jwt",
"issuer": "did:web:winkkey.net",
"subject": "did:web:winkkey.net:users:%40alice",
"issuedAt": 1730000000,
"expiresAt": 1730000300,
"winkAccessTokenSource": "vault-snapshot"
}Decoding the VC. The vc field is a JWS with an EdDSA signature. Header carries kid = did:web:winkkey.net#<key-id>; resolve our DID document at /.well-known/did.json and verify with the matching verificationMethod public JWK.
{
"iss": "did:web:winkkey.net",
"sub": "did:web:winkkey.net:users:%40alice",
"iat": 1730000000,
"exp": 1730000300,
"vc": {
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "WinkKeyAuthentication"],
"issuer": "did:web:winkkey.net",
"validFrom": "2026-04-28T20:00:00Z",
"validUntil": "2026-04-28T20:05:00Z",
"credentialSubject": {
"id": "did:web:winkkey.net:users:%40alice",
"winkTag": "@alice",
"email": "alice@example.com",
"givenName": "Alice",
"familyName": "Example",
"authenticationMethod": "winkkey-passkey",
"authenticatedAt": "2026-04-28T19:58:00Z",
"winkSessionFresh": true,
"winkAccessTokenSource": "vault-snapshot"
}
}
}{ error: "needs_wink_reauth", rid, stepUpUrl }. The merchant runs the user through the existing cloud reauth flow at stepUpUrl, then retries this endpoint. Server-to-server callers without a human in the loop should defer the VC issuance attempt until the user is next live.merchant (caller supplied a valid Wink token), vault-snapshot (server-side snapshot still fresh), vault-refreshed (snapshot was stale, single-shot refresh succeeded)./api/merchant/wink/refreshRefresh a Wink access token
Stateless relay. Send your current Wink access token while it's still valid; we look up the user's refresh token in our vault, refresh against Wink, persist the rotated refresh token, and return the new access token. The 5-minute TTL on Wink access tokens is the freshness contract — call before expiry.
winkAccessTokenstringrequired | The unexpired Wink access token you currently hold (returned to you on the auth-link consume poll, or from a previous /wink/refresh call). |
curl -X POST https://winkkey.net/api/merchant/wink/refresh \
-H "Authorization: Bearer $WINKKEY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "winkAccessToken": "<current Wink access token>" }'{
"winkAccessToken": "eyJhbGciOiJSUzI1NiIs...",
"expiresAt": 1730000300000,
"source": "vault-refreshed"
}400 access_token_expired— you missed the 5-minute window. Generate a fresh auth-link to re-establish the session.400 access_token_unknown— token parses + isn't expired, but we have no vault entry for itssub. Either it's forged, came from a different environment, or the session was explicitly cleared.409 needs_wink_reauth— we had the session but the refresh token has aged out (Wink SSO Session Max, ~10 hours). Response body includes a freshly-mintedrid+urlthe merchant can hand the user for a one-click cloud reauth.
{
"error": "needs_wink_reauth",
"rid": "AKhtBzZgIGOk96zCTCE-FQ",
"url": "https://winkkey.net/v2/stage/auth/AKhtBzZgIGOk96zCTCE-FQ",
"detail": "Wink session can't be refreshed on the user's behalf. Hand the URL to the user — they sign in via Wink cloud, our vault picks up a fresh refresh token, and your next /wink/refresh call will succeed."
}Error reasons
Failed verifies (verified: false) on /api/v1/auth/* return a stable reason field for programmatic handling. Useful when you want to surface a specific message to the user instead of a generic 401.
| reason | meaning |
|---|---|
credential_not_found | The credential ID in the assertion isn't on file. iCloud Keychain might be offering a stale passkey from a previous deployment. |
user_mismatch | Caller signed in as a user other than the link's userHintEmail. The verify page shows a Cancel / Retry card. |
challenge_not_found | Server-side WebAuthn challenge expired or wasn't found. 60s TTL — usually means the user idled. |
origin_mismatch | The ceremony's origin field didn't match the server's allowlist. Check WEBAUTHN_EXTRA_ORIGINS or the iframe parent. |
rpid_mismatch | Credential's rpId didn't match. Should never happen for credentials registered on winkkey.net. |
challenge_mismatch | Lib-level challenge bytes mismatch. Indicates replay or corruption. |
signature_invalid | Assertion signature failed verification. |
counter_replay | Stored counter ≥ assertion counter — anti-cloning trip. |
user_verification_required | Authenticator didn't actually run user verification (Face ID was somehow bypassed). |
needs_wink_reauth | /api/auth/:rid/complete returned 409 — passkey verified but the cached Wink session expired. Step up to Wink cloud to refresh. |
verifier_other | Unclassified verify-time error. Server logs the raw message. |