Introduction
Keystash's APIs belong to the Representational State Transfer (REST) category. They allow you to perform 'RESTful' operations such as reading, modifying, adding or deleting data from your SSH key management system.
Note
Every API request must be authenticated. We recommend using a Personal Access Token — see the Authentication page for details.
What API commands are used by Keystash?
Keystash APIs are plain JSON over HTTP and use the following HTTP verbs:
| Command | Purpose |
|---|---|
| POST | Create an object |
| GET | Fetch one or more objects |
| PUT | Set an objects state or replaces an object |
| PATCH | Update an object |
| DELETE | Remove an object |
Note
All API requests should hit the secured HTTPS endpoint.
Base Endpoint URL
The base REST endpoint URL is:
https://app.keystash.io/api/v1/
All the of the Resources and Methods prefix this base URL.
Example
Resource: Users
Method: List Users
Final URL: https://app.keystash.io/api/v1/users/list
Rate Limit
Keystash implements a rate limit on connections to the API. The API allows:
5 connections/second.
Should you you reach the API limit you will receive the following error response body. The Retry-After value in the response header will tell you how long you need to wait before you can send another API request.
HTTP/1.1 429
X-Ratelimit-Remaining: 0
X-RateLimit-Limit: 1
X-RateLimit-Reset: {datetime}
Retry-After: 10
| Header Name | Description |
|---|---|
| X-RateLimit-Remaining | The number of requests remaining in the current rate limit window. |
| X-RateLimit-Limit | The number of requests used in the current rate limit window. |
| X-RateLimit-Reset | The date and time at which point you can resume submitting requests. |
| Retry-After | The number in seconds that you will have to wait to fire your next API request. |
Filtering Lists
Several of the list endpoints accept query parameters that narrow the results before they are returned to you. The filtering is done in the database, so a narrow request is quicker and cheaper than pulling everything back and sorting it out yourself. The endpoints that accept filters are:
- List Servers
- List SSH Sessions
- List Server Groups
- List Users in Server Group
- List User Groups in Server Group
Each resource page documents the parameters that its endpoint accepts. The rules on this page apply to all of them.
Filters are validated strictly. A value that fails its rule, a parameter the endpoint does not accept, or a malformed date returns a 400 BadArgument before any query is run. The response names every parameter at fault at once, so three mistakes in a request are reported together rather than one at a time over three round trips.
Four properties keep that strictness comfortable to work with:
- A parameter you do not send is never validated. A request with no query string at all is a perfectly normal first page.
- A single value works where an array is expected.
?statuses=Connectedbehaves exactly as?statuses[]=Connecteddoes. - Fixed vocabularies are case-insensitive.
?statuses[]=connectedis accepted and matched against theConnectedstatus. - A boolean set to
falseis a filter, not an absent one.?two_factor_enabled=falsereturns exactly the Server Groups that have the setting switched off.
A limit above the maximum, an offset past the end of the results and a valid filter that happens to match nothing are all handled normally rather than rejected. See Paging Through Lists for what each of those returns.
Each entry in the details array of a rejection describes one parameter that was refused:
| Key | Always Present | Meaning |
|---|---|---|
field |
yes | The query parameter at fault, named exactly as you sent it. |
message |
yes | The rule that the value broke. |
received |
no | The offending value, truncated to 64 characters. For an array, the first member that failed. Absent when there was no value to echo, such as the missing half of a date pair. |
allowed_values |
no | Present when the parameter has a fixed vocabulary. On an unknown parameter it lists every parameter that the endpoint accepts. |
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "BadArgument",
"message": "One or more query parameters were invalid.",
"details": [
{
"field": "statuses",
"message": "Unrecognised status label. Supply one or more of the allowed values.",
"received": "Banana",
"allowed_values": ["Connected", "Syncing", "Warning", "Upgrading", "Disconnected"]
}
]
}
}
Where the parameter name itself is wrong, the message carries a suggestion if the slip is a difference of case, or a singular where a plural was expected. Sending ?provider=Linode to List Servers returns "Unknown query parameter. Did you mean 'providers'?".
Note
Dates are matched against the exact format YYYY-MM-DD HH:mm:ss and are read as UTC. A date without a time, an ISO 8601 value carrying a T or a time zone offset, or a differently separated value is rejected. Send 2026-06-08 23:59:59 rather than 2026-06-08, because a date on its own can only mean midnight, so an end_date of 2026-06-08 would drop the whole day you asked for.
Tip
Array parameters accept both ?statuses[]=Active and ?statuses=Active. Use the bracketed form whenever you are supplying more than one value, repeating the parameter once per value: ?statuses[]=Active&statuses[]=Ended.
Paging Through Lists
The same two parameters page every list that accepts filters:
| Parameter | Default | Accepted | Behaviour |
|---|---|---|---|
limit |
250 |
1–250 |
The number of rows to return. The default and the maximum are the same number, so limit is a reduction knob: you can ask for fewer rows but never for more. A value above 250 is reduced to 250. |
offset |
0 |
0 or greater |
The row offset into the matching set. |
The response body is always a plain JSON array of records. The paging metadata travels in the response headers instead:
| Header | Meaning |
|---|---|
X-Total-Count |
The number of rows matching all of the filters you supplied, before limit and offset are applied. |
X-Limit |
The effective limit after any reduction, rather than the raw value you sent. |
X-Offset |
The effective offset. |
All three headers are exposed through CORS, so browser JavaScript can read them.
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Total-Count: 1100
X-Limit: 250
X-Offset: 250
[ ...250 records... ]
To walk a list, read X-Total-Count from the first response and request pages until you have covered it. If you would rather not track a total, keep requesting pages until one comes back with fewer rows than your limit. Both approaches work.
Warning
The API is limited to 5 requests per second, so page sequentially rather than requesting several pages at once. A fleet of 1,100 servers is 5 requests at 250 rows a page, and a company with 50,000 SSH sessions is 200 requests. Where the set is large, narrow it with a filter, such as a date range on SSH Sessions, rather than walking the whole history.
Note
The list endpoints fall into two families and they treat an empty result differently.
Collection lists are List Servers, List SSH Sessions and List Server Groups.
| Condition | Response |
|---|---|
No filters supplied and X-Total-Count is 0 |
404 NotFound |
| Filters supplied and nothing matched | 200 OK with [] and X-Total-Count: 0 |
An offset past the end of a non-empty set |
200 OK with [] and the true X-Total-Count |
Read that 404 as "you have none of these yet" rather than as an error. It is driven by the total, never by the length of the page you asked for.
Membership lists are List Users in Server Group and List User Groups in Server Group.
| Condition | Response |
|---|---|
| Nothing to list | 200 OK with [] |
An offset past the end |
200 OK with [] and the true X-Total-Count |
An unknown id |
404 NotFound |
Each list has a fixed order, so walking offset = 0, 250, 500 and onwards over a set of data that is not changing underneath you visits every row exactly once. The order is a total one, so even where two records share the same name or hostname their position relative to each other is settled and a page boundary never drops or repeats a row.
| Endpoint | Order |
|---|---|
| List Servers | hostname ascending |
| List SSH Sessions | started_at descending, newest first |
| List Server Groups | name ascending |
| List Users in Server Group | lastname, then firstname, ascending |
| List User Groups in Server Group | name ascending |
Error Responses
Every error the API returns uses the same envelope:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "BadArgument",
"message": "One or more query parameters were invalid.",
"details": []
}
}
| Key | Meaning |
|---|---|
error.code |
A stable, machine-readable code from the table below. Test against this rather than against the message. |
error.message |
A human-readable summary of what went wrong. |
error.details |
An array carrying the specifics. It is empty where there is nothing to add. On a rejected query parameter it carries one entry per parameter at fault, as described in Filtering Lists. |
| Code | Status | When |
|---|---|---|
BadRequest |
400 |
The request itself could not be read. |
BadArgument |
400 |
An argument was missing, or present and invalid. |
Unauthorized |
401 |
The credentials were rejected. |
InvalidPersonalAccessToken |
401 |
The Personal Access Token is unknown, expired or revoked. |
InvalidJSONWebToken |
401 |
The JSON Web Token is invalid or has expired. |
Forbidden |
403 |
The authenticated user's Role does not allow this path. |
NotFound |
404 |
The request succeeded but there was nothing to return. |
DataRetrievalError |
500 |
A problem occurred while reading from the database. |
DataSaveError |
500 |
A problem occurred while writing to the database. |
UnknownSystemError |
500 |
A server-side fault. Retry the request, and log a ticket with us if it persists. |
A 403 names the path you were refused, and there is a worked example of one on the Authentication page.
Note
A rejected request costs you nothing. There is no lockout or penalty for a validation failure, so you can correct your parameters and retry straight away, subject only to the rate limit of 5 requests per second.
Dates and Times
All dates and times in the Keystash API are in UTC time. The date and time format used across the platform is ISO 8601 YYYY-MM-DD HH:mm:ss. Example: 2020-02-26 18:30:26