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.

POST/api/admin/api-keys/request

Request 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.

Body parameters
inviteCode
stringrequired
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.
label
stringrequired
1-64 chars, alphanumeric + ._- (no spaces). Used as the audit prefix on every link this key creates: api:<label>.
requestedByEmail
stringrequired
Recorded as createdByEmail. Not email-verified by us; admin still gates approval.
reason
stringoptional
Short note (≤280 chars) shown to the admin alongside the pending row. Helps the operator decide whether to approve.
Requestbash
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."
  }'
Response (HTTP 201)json
{
  "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."
}
Bearer requests using the returned key receive HTTP 401 until an admin approves. After approval, the same value works without further changes on the caller's side. If the request is rejected the admin revokes the row; the requester then submits a new request.
POST/api/merchant/auth-request

Create a link

Generates a one-time link for either auth (verify existing user) or enrol (onboard new user). Single endpoint, kind flag drives behaviour.

Body parameters
kind
"auth" \| "enroll"optional
Default "auth". Use "enroll" to onboard a new user — server forces forceWinkCloud=true and bumps default TTL to 2 days.
ttlSeconds
numberoptional
Link lifetime. Default 900 (auth) / 172800 (enrol). Capped at 604800 (7 days).
userHintEmail
stringoptional
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".
callbackUrl
stringoptional
If set, the verify page redirects here with ?token=<JWT> after consume. If null, the user lands on a success screen.
forceWinkCloud
booleanoptional
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".
displayTitle
stringoptional
Custom page title (≤ 80 chars). Defaults: "Verify your identity" (auth) / "Create your Wink account" (enrol).
displaySubtext
stringoptional
Custom subheading (≤ 280 chars). Defaults are kind-aware and substitute the merchant name.
Requestbash
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"
  }'
Responsejson
{
  "rid": "Abc...",
  "kind": "auth",
  "url": "https://winkkey.net/v2/stage/auth/Abc...",
  "expiresAt": 1745800000000
}
Env binding: no 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 url as a path segment (/v2/<env>/auth/<rid>) — observable in logs, history, share targets.
  • Scopes the WebAuthn allowCredentials list 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.

GET/api/merchant/auth-request/:rid

Poll 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.

Requestbash
curl https://winkkey.net/api/merchant/auth-request/Abc...
Response (consumed, with Wink token)json
{
  "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" }
}
Status values: pending, consumed, expired, failed (cancelled). consumedMethod is "winkkey" (passkey) or "wink_cloud" (face match).
winkAccessTokenStatus: 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.
Passkey-only consumes on users with no prior Wink session omit the winkAccessToken fields entirely; flagged via winkTokenAvailable: false. The merchant treats resultToken as identity-only.
DELETE/api/merchant/auth-request/:rid

Cancel 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.

Requestbash
curl -X DELETE https://winkkey.net/api/merchant/auth-request/Abc... \
  -H "Authorization: Bearer wkey_…"
Responsejson
{ "ok": true, "rid": "Abc...", "status": "failed" }
POST/api/merchant/auth-request/:rid?action=renew

Renew 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.

Requestbash
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 }'
Responsejson
{ "ok": true, "rid": "Abc...", "expiresAt": 1745800000000, "status": "pending" }
POST/api/merchant/auth-request/:rid/send-email

Email 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.

Requestbash
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" }'
Responsejson
{ "ok": true, "sentTo": "user@example.com" }
GET/api/auth/:rid

Public 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.

Responsejson
{
  "rid": "Abc...",
  "merchantId": "winkkey",
  "env": "stage",
  "status": "pending",
  "expiresAt": 1745800000000,
  "userHintEmail": null,
  "hasCallback": true,
  "userHasWinkKey": null,
  "forceWinkCloud": false,
  "kind": "auth",
  "displayTitle": null,
  "displaySubtext": null
}
userHasWinkKey: when 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.
GET/api/wink/signing-key

Wink 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.

Requestbash
curl https://winkkey.net/v2/api/wink/signing-key \
  -H "Authorization: Bearer wkey_..."
Responsejson
{
  "env": "stage",
  "keyId": "23c96f21-7c0e-4a25-a4ba-8b05ab1e86ac",
  "merchantId": "winkkey",
  "pem": "-----BEGIN PRIVATE KEY-----\n..."
}
Scope: caller must hold thesigning-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 resolution: returned env matches the api-key's binding. Keys with 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.
Caching: returned withCache-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.
EVTwindow.postMessage

Embed 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.

Listeningjs
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;
  }
});
Note: 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.
POST/api/merchant/vc/issue

Issue 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.

Body parameters
winkAccessToken
stringoptional
A Wink-issued access token. Validated via Wink userinfo. We do not refresh tokens we didn't issue — expired tokens get a 400.
resultToken
stringoptional
A WinkKey result token from auth-link consume. We use the snapshot captured at consume time, refreshing once via the vault if stale.
audience
stringoptional
Optional VC audience. When set, verifiers SHOULD reject the VC unless presented to this audience.
ttlSeconds
integeroptional
VC validity window in seconds (default 300, max 3600).
Requestbash
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>"
  }'
Responsejson
{
  "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.

VC payload (decoded)json
{
  "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"
    }
  }
}
Step-up required (HTTP 409). When the resultToken path encounters a stale vault that can't be refreshed (refresh_token revoked / expired / absent), we return: { 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.
winkAccessTokenSource values: merchant (caller supplied a valid Wink token), vault-snapshot (server-side snapshot still fresh), vault-refreshed (snapshot was stale, single-shot refresh succeeded).
POST/api/merchant/wink/refresh

Refresh 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.

Body parameters
winkAccessToken
stringrequired
The unexpired Wink access token you currently hold (returned to you on the auth-link consume poll, or from a previous /wink/refresh call).
Requestbash
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>" }'
Response — 200json
{
  "winkAccessToken": "eyJhbGciOiJSUzI1NiIs...",
  "expiresAt": 1730000300000,
  "source": "vault-refreshed"
}
Failure modes:
  • 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 its sub. 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-minted rid + url the merchant can hand the user for a one-click cloud reauth.
Response — 409 needs_wink_reauthjson
{
  "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.

reasonmeaning
credential_not_foundThe credential ID in the assertion isn't on file. iCloud Keychain might be offering a stale passkey from a previous deployment.
user_mismatchCaller signed in as a user other than the link's userHintEmail. The verify page shows a Cancel / Retry card.
challenge_not_foundServer-side WebAuthn challenge expired or wasn't found. 60s TTL — usually means the user idled.
origin_mismatchThe ceremony's origin field didn't match the server's allowlist. Check WEBAUTHN_EXTRA_ORIGINS or the iframe parent.
rpid_mismatchCredential's rpId didn't match. Should never happen for credentials registered on winkkey.net.
challenge_mismatchLib-level challenge bytes mismatch. Indicates replay or corruption.
signature_invalidAssertion signature failed verification.
counter_replayStored counter ≥ assertion counter — anti-cloning trip.
user_verification_requiredAuthenticator 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_otherUnclassified verify-time error. Server logs the raw message.