---
title: "Create an API key"
method: POST
path: "/api/v1/api-keys"
tags: ["ApiKeys"]
---

# Create an API key

`POST /api/v1/api-keys`

## Request

- Base URL: `https://app.lastping.dev`
- URL: `https://app.lastping.dev/api/v1/api-keys`
- Auth: API key in header `X-Api-Key`

Creates a new API key for the authenticated project. The plaintext
`key` is returned **exactly once**, in this response — it is never
stored, logged, or retrievable again through any other endpoint.
Store it immediately.

`expires_at`, when supplied, must be in the future; a past value is
rejected with 400 rather than silently minting an already-dead key.

**A key minted by a key can never outlive its creator.** If this
request is authenticated with an API key that itself has an expiry, an
explicit `expires_at` later than that key's own is rejected with 400
(`expires_at may not exceed the creating key's own expiry`). The
problem body carries `max_expires_at`, the latest value this caller
may ask for, so a client can retry correctly on its first attempt
rather than guessing. This cap does not apply to session-authenticated
(dashboard) requests, or when the authenticating key itself never
expires.

An **omitted** `expires_at` follows the same rule, and it is in force:
under a creating key that itself expires, omitting the field inherits
that key's own expiry rather than meaning "never". A short-lived key
therefore cannot mint a permanent one, whether it omits the field or
asks for too much.

Nothing here changes for the other callers: under a creating key with
no expiry of its own, and for session-authenticated (dashboard)
requests, an omitted `expires_at` still means "never".

A client that wants to choose a lifetime rather than inherit one should
send an explicit `expires_at` and retry at `max_expires_at` if refused,
which is what the MCP `create_api_key` tool does.

**A key may not be given a higher scope than its creator either.**
The same shape as the expiry cap above: an explicit `scope` that
outranks the authenticating key's own is rejected with 400
(`scope may not exceed the creating key's own scope`), and the
problem body carries `max_scope`. An omitted `scope` is always
`write`, which no key can fail to grant except a `read` key — and a
`read` key cannot reach this operation at all.

## Request body

- ApiKeyCreate: Body for POST /api/v1/api-keys.
  - `name` string, required: Human-readable label for the key, e.g. "github-actions".
  - `expires_at` string, date-time: Optional RFC 3339 expiry. Must be in the future — a past `expires_at` is rejected with 400, since minting an already-dead key is always a caller error. Omit for a key that never expires. **Exception — key-authenticated callers only:** if the request is authenticated with an API key that itself has an expiry, the new key can never outlive it. Omitting `expires_at` then inherits the authenticating key's own expiry instead of "never", and supplying a later `expires_at` is rejected with 400. A session-authenticated caller (the dashboard) and a key with no expiry of its own impose no such cap.
  - `scope` 'read' | 'write' | 'admin' | 'ingest': What the new key may do. `read` is every `GET`; `write` is everything except API key management; `admin` is everything, key management included. `ingest` is not a tier above or below those three: an ingest key can send pings, traces, metrics and logs to the ingest host and **cannot call this API at all** (every route answers 403 `INSUFFICIENT_SCOPE`, whatever the deployment's scope-enforcement setting). It is the key to put in a dotfile or an exporter's config. Minting one through this route needs an `admin` key (the route is admin-only) or a signed-in session. A `write` key mints a monitor-bound one through `POST /api/v1/checks/{id}/ingest-keys`, which can mint nothing else. **Omitted means `write`**, on every surface. That is the right tier for a credential handed to a CI job or an agent: it can do the work and cannot mint itself a replacement that survives the original's revocation. **A key may not be given a HIGHER scope than the key that creates it.** Asking for one is rejected with 400 (`scope may not exceed the creating key's own scope`), and the problem body carries `max_scope` — the highest tier this caller may ask for — so a client can retry correctly on its first attempt. As with the `expires_at` cap, this does not apply to session-authenticated (dashboard) requests: the session is the account owner.
  - `check_id` string, uuid: Binds an `ingest` key to one monitor. Only allowed with `scope: "ingest"`; any other scope with a `check_id` is rejected with 400, and a monitor that is not in this project is 404. A bound key narrows, never widens: telemetry it sends is always attributed to this monitor, and a payload naming a different `lastping.monitor_id`, or a monitor URL that is not this monitor, is refused with 400. Use it for an exporter that can set a header but not a resource attribute. Deleting the monitor deletes the key; regenerating the key keeps the binding.

## Response `201`

API key created. `key` is the plaintext credential — shown exactly once.

- ApiKeyCreated: An API key returned by the API. The plaintext key value is NEVER included here — only the non-secret `prefix` is returned, so this shape is used for both list and the non-secret half of create.
  - `id` string, uuid, required: Unique API key identifier.
  - `name` string, required: Human-readable label for the key.
  - `prefix` string, required: First 10 characters of the key — a non-secret identifier shown in place of the full value.
  - `created_at` string, date-time, required: RFC 3339 UTC timestamp when the key was created.
  - `expires_at` string, date-time: RFC 3339 UTC expiry timestamp, or omitted for a key that never expires.
  - `last_used_at` string, date-time: RFC 3339 UTC timestamp of the most recent request this key authenticated. Omitted if the key has never been used since this field was added — never rendered as a zero/epoch time.
  - `last_used_surface` 'mcp' | 'terraform' | 'api' | 'ingest': Which client surface last used this key, omitted if unknown. `ingest` means the latest use was sending telemetry (traces, metrics or logs); telemetry use is recorded about once a minute per key, so `last_used_at` can trail the newest export by up to a minute. For `mcp`, `terraform` and `api` the value is best-effort and spoofable: derived from the caller-controlled `User-Agent` header on the authenticating request, not from any cryptographic or protocol-level signal. Useful for an honest "did my client ever successfully authenticate?" indicator — never use it for authorization or access control.
  - `scope` 'read' | 'write' | 'admin' | 'ingest', required: What this key is permitted to do. Always present. Unlike `last_used_surface` above — which is a spoofable hint derived from a caller-supplied header — this is the value the server actually checks. `ingest` sends telemetry only and cannot call this API.
  - `check_id` string, uuid: The one monitor an `ingest` key is bound to. Omitted for an unbound key and for every key of another scope.
  - `created_by_key_id` string, uuid: The API key that minted this one, omitted for a key created from the dashboard (which has no parent key) or whose parent has since been deleted. **Revoking a key revokes every key below it in this chain.** A key minted by a key minted by the key you delete goes with it, all at once. A key created from the dashboard is never below anything and is unaffected.
  - `key` string, required: The plaintext API key. Store it immediately — it cannot be retrieved again.

## Other responses

- `400`: Missing `name`, `expires_at` is not in the future, or (when authenticated with a key that itself expires) `expires_at` exceeds that key's own expiry (`expires_at may not exceed the creating key's own expiry`). That last case, and only that case, adds a `max_expires_at` member to the problem body: the latest `expires_at` this caller may ask for, as an RFC 3339 timestamp. It is the authenticating key's own expiry, and it is always itself an acceptable value, so a client may resend the request with it verbatim. A fourth case has no `expires_at` in the request at all, and arises only where an omitted expiry is inherited: if the authenticating key's own expiry passed while the request was being handled, the expiry it would hand down is already dead, and the request is refused with `the creating key has expired` rather than minting a key that could never be used. A fifth case is an unrecognised `scope`, or an explicit `scope` that outranks the authenticating key's own (`scope may not exceed the creating key's own scope`) — that last case, and only that case, adds a `max_scope` member instead.
- `401`: Missing or invalid API key.
- `403`: The API key authenticated, but its scope does not reach this operation. The problem body carries `required_scope`: the tier the operation needs. Re-mint the key at that scope (Settings, `POST /api/v1/api-keys`, or the `create_api_key` MCP tool) rather than retrying — the refusal is deterministic and a retry will be refused identically.
- `429`: Too many requests. The per-principal fixed-window rate limit has been exceeded. Retry after the number of seconds indicated by the `Retry-After` response header. The problem body's `code` is `RATE_LIMITED`. It names this limiter specifically, so a client can tell it apart from the application-level throttles that also answer 429 with their own codes and their own, much longer, `Retry-After` values (`TEST_PING_RATE_LIMITED`, `TOO_MANY_ATTEMPTS`). Back off globally on `RATE_LIMITED`; back off on the one operation for the others. The principal is the API key for a key-authenticated request and the USER for a browser session, so every tab one person has open shares one budget.

---

[API](https://skmtc.dev/lastping/apis/lastping-management-api.md) · [All operations](https://skmtc.dev/lastping/apis/lastping-management-api/llms.txt) · [OpenAPI document](https://skmtc.dev/lastping/apis/lastping-management-api/revisions/bbc6eda91c46?raw)
