Concepts
Authentication
Authenticate server-to-server calls with API keys or OAuth2 client credentials. Use the publishable key only in browsers. This page covers key formats, scopes, rotation, environment binding, IP allow-lists, rate limits and security practices.
Overview
All /v1 endpoints require a bearer credential in the Authorization header:
Authorization: Bearer vm_test_EeoZZxa3_R0j4wviRzYMghPw3FiZ4_Fzd4DF-UaCEifKpd2X8mQw
There are three kinds of credential:
| Credential | Looks like | Use it for | Where it lives |
|---|---|---|---|
| Secret API key | vm_test_... or vm_live_... | Server-to-server API calls | Your backend only |
| OAuth2 access token | vm_at_... | Same API, short-lived (1 hour), obtained with client credentials | Your backend only |
| Publishable key | vm_pk_test_... or vm_pk_live_... | Identifying your workspace to the Web SDK in a browser | Front-end code, public |
API keys
A key has the form vm_<environment>_<8-char id>_<secret>. The first two parts (vm_test_EeoZZxa3) are the key prefix, used to look the key up and shown in the portal. The rest is the secret. Only a SHA-256 hash of the secret is stored, so a lost key cannot be recovered, only replaced.
- Create keys in the portal under Developers, API keys. Creating a key requires re-entering your password (step-up). The full key is shown once.
- Each key has a name, a set of scopes, an optional expiry and an optional IP allow-list.
- Key last-used time is recorded (at most once a minute) so you can spot dead keys.
- Revoke a key at any time. Revocation takes effect immediately.
Environment binding
The environment is part of the key. A deployment accepts only keys of its own environment:
| Deployment | Accepts | Rejects |
|---|---|---|
| Sandbox | vm_test_... | vm_live_... with 401 |
| Production | vm_live_... | vm_test_... with 401 |
Environment mismatch is deliberately indistinguishable from a wrong key: both return 401 VERIFYME_UNAUTHENTICATED. GET /v1/ping returns the environment the server is running (test or live), your tenantId and the key's scopes, which makes it a good health check.
Scopes
Grant the minimum a service needs. A missing scope returns 403 VERIFYME_INSUFFICIENT_SCOPE, with the scope named in the message.
| Scope | Allows | Default for new keys |
|---|---|---|
onboarding.create | POST /v1/onboarding-requests, POST .../{id}/reissue-link | yes |
onboarding.read | GET /v1/onboarding-requests, GET .../{id}, GET /v1/flows | yes |
onboarding.result | GET .../{id}/result, GET .../{id}/documents/{documentId} (personal data) | yes |
onboarding.cancel | POST .../{id}/cancel | yes |
webhook.manage | /v1/webhook-endpoints (create, list, update, delete, test) and GET /v1/webhook-events | no |
billing.read | GET /v1/usage | no |
A typical split: the service that starts onboarding gets onboarding.create and onboarding.read; the service that reads results gets onboarding.result only; a back-office job that cancels gets onboarding.cancel.
Rotation
Rotate keys at least every 90 days and whenever a person with access leaves. Rotation in the portal issues a new key for the same client and keeps the old one valid for an overlap window (24 hours by default) so you can deploy without downtime. The response includes oldKeyExpiresOn.
- Rotate the key in the portal and copy the new key.
- Deploy the new key to your secret store and roll your services.
- Confirm calls succeed with the new key (
lastUsedOnupdates on the new key). - The old key stops working at
oldKeyExpiresOn, or revoke it immediately.
IP allow-list
A key can be restricted to a list of source IPs. Requests from any other address get 403 VERIFYME_FORBIDDEN ("Request IP is not in the allow-list for this credential.") and a security event is recorded. Enter plain addresses (IPv4 or IPv6); a single host may also be written with /32. Ranges are not matched: list each egress address.
OAuth2 client credentials
If your platform prefers short-lived tokens, exchange a key for an access token. client_id is the key prefix and client_secret is the secret part after the prefix.
# Key: vm_test_EeoZZxa3_R0j4wviRzYMghPw3FiZ4_Fzd4DF-UaCEifKpd2X8mQw
curl -X POST "https://kyc.example.com/v1/oauth/token" \
-d "grant_type=client_credentials" \
-d "client_id=vm_test_EeoZZxa3" \
--data-urlencode "client_secret=R0j4wviRzYMghPw3FiZ4_Fzd4DF-UaCEifKpd2X8mQw"const [, env, id, secret] = process.env.VERIFYME_API_KEY.match(/^vm_(test|live)_([A-Za-z0-9]{8})_(.+)$/);
const res = await fetch(`${process.env.VERIFYME_HOST}/v1/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: `vm_${env}_${id}`,
client_secret: secret,
}),
});
const { access_token, expires_in } = await res.json(); // cache it; refresh before expires_inimport os, re, requests
env, key_id, secret = re.match(r"^vm_(test|live)_([A-Za-z0-9]{8})_(.+)$", os.environ["VERIFYME_API_KEY"]).groups()
r = requests.post(
f"{os.environ['VERIFYME_HOST']}/v1/oauth/token",
data={"grant_type": "client_credentials", "client_id": f"vm_{env}_{key_id}", "client_secret": secret},
timeout=15,
)
r.raise_for_status()
token = r.json()["access_token"]{
"access_token": "vm_at_QtNRWxcZbpd1tBNLdfG9-GfcIqE6ZW8eHWKeXlxwqgM",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "onboarding.create onboarding.read onboarding.result onboarding.cancel"
}
Use it exactly like a key: Authorization: Bearer vm_at_.... The token carries the same scopes, environment and IP allow-list as the key it was issued for, and is rate limited as that key. There is no refresh token: request a new one shortly before expires_in elapses. The token endpoint accepts application/x-www-form-urlencoded only. An unsupported grant_type returns 400 VERIFYME_VALIDATION_ERROR (UNSUPPORTED_GRANT); bad credentials return 401. Revoking the key stops new tokens; tokens already issued live until they expire.
Publishable key versus secret key
| Secret key | Publishable key | |
|---|---|---|
| Prefix | vm_test_ / vm_live_ | vm_pk_test_ / vm_pk_live_ |
| Secret? | Yes. Never ship in browsers or apps | No. Safe in page source |
| Can create requests or read results | Yes (by scope) | No |
| Purpose | Authenticate server API calls | Tell the Web SDK / hosted UI which workspace the page belongs to |
| Rotation | Self-service, with overlap | Tied to the workspace; visible in the portal on the API keys page |
The publishable key is checked when an applicant session is opened: if you pass it, it must belong to the same workspace as the link, otherwise the link is treated as invalid. It does not grant any access on its own. Browser origin control is a separate setting, Allowed origins, in the portal.
Rate limits and headers
Every authenticated /v1 response carries rate limit headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for this key (default 600; deployments can change it) |
X-RateLimit-Remaining | Requests left in the current one-minute window |
X-RateLimit-Reset | Seconds until the window resets (not an epoch timestamp) |
Retry-After | On 429 only: seconds to wait before retrying |
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 42
X-Request-Id: trc_01M3TRRXR1FPQV13143S
Exceeding the limit returns 429 VERIFYME_RATE_LIMITED. Back off using Retry-After with jitter. In addition: repeated failed authentication from one IP is limited and raises a security alert, and the applicant-facing endpoints have their own limits (OTP sends per request and per number, link opening per IP). More in Errors and limits.
Security best practices
- Keep secret keys in a secrets manager or environment variables, never in source control, mobile apps, front-end bundles, logs or support tickets.
- Use a separate key per service and per environment, with only the scopes it needs.
- Set an IP allow-list on production keys when you have static egress IPs.
- Rotate on a schedule and on staff changes; use the overlap window to avoid downtime.
- Create onboarding requests only from your backend. The
onboardingUrlis a bearer link: send it only to the intended applicant over a trusted channel, and usereissue-linkif it may have leaked. - Do not log
Authorizationheaders,onboardingUrlvalues or result bodies. VerifyMe redacts tokens from its own logs and API call history. - Verify webhook signatures on every delivery and reject stale timestamps. See Webhooks.
- Treat results as personal data under the DPDP Act: restrict who can call
onboarding.result, store the minimum, and delete according to your retention policy. - Monitor for
401and403spikes. A burst of401from an unknown IP can mean a leaked or guessed key.