# Requesting API keys for sub-agents

An agent orchestrating other agents, or a CI job that spins up a tool, often needs to hand that sub-agent its own SendOps credential: narrower than its own, and short-lived. Passing on your own key gives the sub-agent everything you have. Asking a person to create a key by hand breaks the automation.

An **API key request** sits between the two. A credential already working in an organization asks for a new key with a subset of its own scopes. An org owner or admin is notified and approves or denies it in the dashboard. Once approved, the same credential claims the key and gets its secret once.


  A request creates no key. Only an owner or admin approving it in the dashboard does, and they can narrow the scopes or shorten the lifetime but never widen either. No API call or MCP tool approves a request.


## The flow

1. **Request.** `POST /v1/api-key-requests` with a name, the scopes, an optional lifetime and a reason. The response is the pending request and a one-time `claim_token`.
2. **Approve.** The organization's owners and admins get an in-app notification and an email. One of them approves or denies the request on the API Keys settings page. They have **24 hours**; after that the request lapses.
3. **Poll.** `GET /v1/api-key-requests/{id}` until `status` is no longer `pending`.
4. **Claim.** Once `approved`, `POST /v1/api-key-requests/{id}/claim` with the `claim_token`, **within one hour** of approval. The response carries the new key's secret, once.

All three calls need the `api.api_keys.request` scope, and all three must come from the same credential.

## Who can request

The caller is an **API key** holding `api.api_keys.request`, or an **OAuth connection** (including an MCP client) whose grant includes it. The scope is never on a key unless someone added it: an owner or admin picks it, as **Request API keys**, when creating or editing a key in the dashboard, and an OAuth connection has it only if the person connecting approved it on the consent screen.

A `client_credentials` OAuth token is refused with `422`: it acts for no person, so there is no grant to tie the request to, claim it with, or revoke it with. Use an API key, or a user-authorized OAuth connection.

## The rules

| Rule | Detail |
|---|---|
| Scopes only narrow | `scopes` must be a subset of the caller's own scopes. Every scope must be one an API key can hold. |
| No chains | `api.api_keys.request` can never be requested, and a minted key can never be given it. A key you obtained this way cannot request keys of its own. |
| Same environment | The key is in the caller's environment: a `sk_test_` key or `oc_test_` client gets a test key. |
| Always expires | `expires_in_days` defaults to **30** and is at most **90**. The lifetime starts when the key is claimed. The approving admin may shorten it. |
| Cannot be extended | A minted key cannot be rotated or cloned in the dashboard; either would extend a lifetime an admin approved. Request a new key instead. |
| Request window | A pending request lapses after **24 hours** if nobody decides it. |
| Claim window | An approved request must be claimed within **1 hour** of approval, or it lapses. |
| One claim | The secret is returned once. A second claim is `409`. |
| Caller must still qualify | At claim time the calling credential must still be active and still hold every approved scope, and the approving admin must still be an active member able to grant them. |
| Pending caps | At most **3** pending requests per credential and **5** per organization. |
| Request rate | At most **10** requests per credential per hour. |

## Revocation cascades

A minted key is tied to the credential that asked for it:

- **Revoking** the requesting API key revokes every key it obtained, in the same transaction, and lapses its open requests.
- **Disconnecting** the requesting OAuth connection (from Connected Apps, or by an org admin) does the same for the keys it obtained.
- **Rotating** the requesting API key moves its minted keys to the new key, so they stay valid. When the old key's 24-hour grace ends, the minted keys are untouched.

Each minted key also expires on its own at the end of its approved lifetime. A minted key can be revoked on its own at any time, like any other key.

## Worked example

A CI job holds `$SENDOPS_KEY`, a live key with `api.api_keys.request`, `api.contacts.view` and `api.lists.view`. It wants a read-only key for a reporting sub-agent.

### 1. Request

```bash
curl -X POST https://api.sendops.dev/v1/api-key-requests \
  -H "Authorization: Bearer $SENDOPS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-report-agent",
    "scopes": ["api.contacts.view", "api.lists.view"],
    "expires_in_days": 7,
    "reason": "Read-only access for the nightly list report sub-agent."
  }'
```

`201 Created`:

```json
{
  "id": "0192f1a4-7b3c-7d2e-9f10-2a3b4c5d6e7f",
  "status": "pending",
  "name": "nightly-report-agent",
  "environment": "live",
  "scopes": ["api.contacts.view", "api.lists.view"],
  "expires_in_days": 7,
  "reason": "Read-only access for the nightly list report sub-agent.",
  "request_expires_at": "2026-10-16T09:00:00Z",
  "created_at": "2026-10-15T09:00:00Z",
  "claim_token": "kr_..."
}
```

Store `id` and `claim_token` now. The token is shown only in this response; SendOps keeps only a hash of it.

### 2. Poll status

```bash
curl https://api.sendops.dev/v1/api-key-requests/$REQUEST_ID \
  -H "Authorization: Bearer $SENDOPS_KEY"
```

```json
{
  "id": "0192f1a4-7b3c-7d2e-9f10-2a3b4c5d6e7f",
  "status": "approved",
  "name": "nightly-report-agent",
  "environment": "live",
  "scopes": ["api.contacts.view", "api.lists.view"],
  "approved_scopes": ["api.lists.view"],
  "expires_in_days": 7,
  "approved_expires_in_days": 3,
  "reason": "Read-only access for the nightly list report sub-agent.",
  "deny_reason": null,
  "request_expires_at": "2026-10-16T09:00:00Z",
  "claim_expires_at": "2026-10-15T10:12:00Z",
  "created_at": "2026-10-15T09:00:00Z",
  "decided_at": "2026-10-15T09:12:00Z"
}
```

Here the admin narrowed the request: only `api.lists.view`, for 3 days. Always read `approved_scopes` and `approved_expires_in_days` rather than assuming you got what you asked for.

| `status` | Meaning | Next |
|---|---|---|
| `pending` | Waiting for an admin. | Poll again. A minute or more between polls is plenty; the decision is made by a person. |
| `approved` | Approved, possibly narrowed. | Claim before `claim_expires_at`. |
| `denied` | An admin refused it. `deny_reason` may say why. | Stop. Do not re-request the same thing in a loop. |
| `claimed` | The key was issued. | Nothing; the secret cannot be shown again. |
| `lapsed` | Nobody decided within 24 hours, nobody claimed within an hour of approval, or the requesting credential was revoked. | Make a new request if you still need the key. |

The status read never returns a secret, and only the credential that made the request can read it: any other caller, even another key in the same organization, gets `404`.

### 3. Claim

```bash
curl -X POST https://api.sendops.dev/v1/api-key-requests/$REQUEST_ID/claim \
  -H "Authorization: Bearer $SENDOPS_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"claim_token\": \"$CLAIM_TOKEN\"}"
```

`201 Created`:

```json
{
  "key": {
    "id": "0192f1b0-1c2d-7e3f-8a9b-0c1d2e3f4a5b",
    "name": "nightly-report-agent",
    "environment": "live",
    "key_prefix": "a1b2c3d4",
    "scopes": ["api.lists.view"],
    "expires_at": "2026-10-18T09:15:00Z"
  },
  "secret": "sk_live_..."
}
```

Hand `secret` straight to the sub-agent's configuration. In the dashboard the key shows **Requested by** with the name of the credential that asked.

## Errors

Errors are [RFC 7807 problem documents](/api-reference/errors), as everywhere else in the API.

| Status | `code` | When | What to do |
|---|---|---|---|
| `403` | `invalid_scope` | The credential lacks `api.api_keys.request`. | An owner or admin adds **Request API keys** to the key. |
| `404` | `not_found` | No such request, or it was made by a different credential. | Use the `id` from your own request, with the credential that made it. |
| `409` | `conflict` | Claiming a request that is still pending, was denied, or was already claimed; claiming after the one-hour claim window (the request has lapsed); or the requesting credential has been revoked or has expired. `detail` says which. | `GET` the request for its status. If it has lapsed, make a new request. |
| `422` | `validation_failed` | A scope you do not hold, an unknown scope, a scope an API key cannot hold, `api.api_keys.request` itself, no scopes, `expires_in_days` outside 1–90, a `reason` over 500 characters, an invalid `claim_token`, a `client_credentials` token, a credential that no longer holds an approved scope at claim, or an approving admin who is no longer able to grant it. | Fix the request. The approver cases need a new request. |
| `429` | `rate_limited` | More than 10 requests by this credential in an hour, or the pending caps (3 per credential, 5 per organization) are full. `detail` says which limit. | For the hourly limit, wait for `Retry-After`. The pending caps carry no `Retry-After`: wait for an admin to decide the pending requests, or for them to lapse. |

The usual per-organization [rate limit](/api-reference/rate-limiting) applies to these endpoints too, and its `429` looks the same; read `detail` to tell them apart.

## Security notes


  The `claim_token` is half of what turns an approved request into a working key. It is useless without the credential that asked, but store it as you would that credential: in memory or your secret store, never in logs, issue comments or commit messages.


- **The secret is shown once.** The claim response is sent with `Cache-Control: no-store`, and SendOps stores only a hash of the key. If you lose it, revoke the key and request another.
- **Do not log the claim response.** Write `secret` straight into the sub-agent's environment variable or secret store.
- **Ask for the least you need.** Request the fewest scopes and the shortest lifetime that do the job. The admin sees your `reason`; a clear one gets a faster decision.
- **Do not retry a request blindly.** Each `POST /v1/api-key-requests` is a new request, notifies the admins again, and counts toward the pending caps and the hourly limit. If a request call failed in transit, wait a moment and check with the admin before asking again.
- **Plan for revocation.** A minted key dies with the credential that asked for it. If your sub-agent must outlive the parent, ask an admin to create a regular key instead.

## From an MCP agent

An agent connected over [MCP](/api-reference/mcp) does the same through the `api_key_requests` tool, with `action` set to `request`, `status` or `claim`. See [MCP Server → API keys](/api-reference/mcp#api-keys).

## Endpoints

| Endpoint | Does |
|---|---|
| [`POST /v1/api-key-requests`](/api-reference/endpoints/api-keys/request-apikey) | Ask for a narrower key. Returns the one-time `claim_token`. |
| [`GET /v1/api-key-requests/{id}`](/api-reference/endpoints/api-keys/get-apikey-request) | The request's status and what was approved. Never a secret. |
| [`POST /v1/api-key-requests/{id}/claim`](/api-reference/endpoints/api-keys/claim-apikey-request) | Issue the approved key and return its secret, once. |