The Attia API
Read your workspace's data from your own programs: keys, pagination, errors, rate limits and versioning.
Overview
The Attia API lets your own programs read data from a workspace over HTTPS. Every address starts with https://attia.app/api/v1, every answer is JSON, and a personal API key identifies you.
Version 1 has one operation so far, List jobs. This page covers what every operation shares.
Authentication
Create a key under Settings → Security & access → Personal API keys, with at least the Read permission. Attia shows the key once, so copy it then. A key starts with attia_api_.
A key acts as you. It reads what you can read in the workspace, limited further by the permissions and teams you gave it. Revoke a key on the same page and it stops working at once.
Send the key in the Authorization header as a bearer token. The bare key without Bearer works too.
curl https://attia.app/api/v1/jobs \ -H "Authorization: Bearer $ATTIA_API_KEY"Call the API from a server or a script, never from a web page. The API sends no CORS headers, so a browser refuses the answer, and a key in page source is a key anyone can copy.
If your workspace restricts access by IP address, the same list applies to API calls. A call from another address gets ip-not-allowed. Workspace owners are exempt, as they are in the app.
Your first call
Put the key in an environment variable and ask for two jobs.
export ATTIA_API_KEY="attia_api_..." curl "https://attia.app/api/v1/jobs?limit=2" \ -H "Authorization: Bearer $ATTIA_API_KEY"A list answers with data and next_cursor.
{ "data": [ { "id": "0192f3a1-6b02-7d4c-8e11-5a9c0b7d3e01", "title": "Backend developer", "slug": "backend-developer", "status": "live", "team_id": "0192f39f-11aa-7c3e-b2d4-9e8f7a6b5c4d", "employment_type": "full_time", "contract_type": "permanent", "workplace_type": "hybrid", "locations": ["Eksempelvik"], "positions_count": 2, "application_deadline": "2026-11-15", "published_at": "2026-09-28T07:30:00.000000Z", "closed_at": null, "archived_at": null, "created_at": "2026-09-25T12:04:51.220417Z", "updated_at": "2026-10-01T09:42:10.118305Z" }, { "id": "0192f3a1-7c44-7b1e-9a50-3f1d2c6e8a02", "title": "Store manager", "slug": "store-manager", "status": "draft", "team_id": "0192f39f-11aa-7c3e-b2d4-9e8f7a6b5c4d", "employment_type": "part_time", "contract_type": "temporary", "workplace_type": "onsite", "locations": [], "positions_count": 1, "application_deadline": null, "published_at": null, "closed_at": null, "archived_at": null, "created_at": "2026-10-02T08:15:30.482913Z", "updated_at": "2026-10-02T08:15:30.482913Z" } ], "next_cursor": "eyJ1IjoiMjAyNi0xMC0wMlQwODoxNTozMC40ODI5MTNaIiwiaSI6IjAxOTJmM2ExLTdjNDQtN2IxZS05YTUwLTNmMWQyYzZlOGEwMiJ9"}Pagination
A list returns 25 items unless you set limit, which takes 1 to 100. When more items exist, next_cursor holds a string. Send it back as cursor to get the next page, and stop when next_cursor is null.
curl "https://attia.app/api/v1/jobs?limit=2&cursor=eyJ1IjoiMjAyNi0xMC0wMlQwODoxNTozMC40ODI5MTNaIiwiaSI6IjAxOTJmM2ExLTdjNDQtN2IxZS05YTUwLTNmMWQyYzZlOGEwMiJ9" \ -H "Authorization: Bearer $ATTIA_API_KEY"Treat a cursor as an opaque string. Do not build or change one.
Every list sorts by updated_at, oldest first, then by id. That order keeps a cursor stable while people edit. An item edited during your read moves behind the cursor, and you may see it again on a later page. Expect the occasional item twice and key on id. One pass can still miss a change saved while you read, so poll as the next paragraph describes.
updated_since returns only items updated at or after a time. The bound is inclusive, so an item updated at exactly that time comes back. To poll for changes, remember the largest updated_at you have seen, and on the next run send a time five minutes before it as updated_since. A change gets its updated_at when the save starts, so a save that finishes after your poll can carry a time older than one you already read. The five-minute overlap is there to pick those up. It also returns items you already have, so drop the repeats by id.
Timestamps, nulls and enums
A timestamp is a string in UTC, in RFC 3339 form with microseconds and a Z, such as 2026-10-02T08:15:30.482913Z. A field that holds a calendar day with no time, such as application_deadline, is YYYY-MM-DD.
A timestamp you send, such as updated_since, needs a Z or an offset. Write a + in an offset as %2B, because a bare + in a URL means a space.
curl "https://attia.app/api/v1/jobs?updated_since=2026-10-01T00:00:00Z" \ -H "Authorization: Bearer $ATTIA_API_KEY"A field with no value is present and null. The API never leaves a field out, so check for null and not for a missing key. An empty list is [].
An enum is a lower-case string such as live or full_time. Version 1 can add a value to an enum, so handle a value you do not know without failing.
Errors
An error answers with the matching HTTP status and an RFC 9457 problem document. The content type is application/problem+json.
curl -i https://attia.app/api/v1/jobs \ -H "Authorization: Bearer attia_api_not_a_real_key"{ "type": "https://attia.app/docs/api#invalid-api-key", "title": "Invalid API key", "status": 401, "detail": "The request has no API key, or the key is unknown, revoked or expired.", "code": "invalid-api-key"}Branch on code. It never changes, while title and detail are for people and may be reworded. type links to the code's section below.
invalid-api-key
Status 401. The request has no key, the key is mistyped, or the key is unknown, revoked or expired. Create a new key and send it in the Authorization header.
insufficient-scope
Status 403. The key is valid and lacks the permission the operation needs. Reading needs Read. Create a key that has it.
ip-not-allowed
Status 403. The workspace restricts access by IP address and the call came from an address outside the list. Call from a listed address, or ask a workspace admin to add yours.
invalid-request
Status 400. A query parameter is unknown, repeated or has a value the API does not accept. errors names each one.
{ "type": "https://attia.app/docs/api#invalid-request", "title": "Invalid request", "status": 400, "detail": "One or more parameters are not valid.", "code": "invalid-request", "errors": [{ "field": "limit", "message": "Must be an integer from 1 to 100." }]}rate-limited
Status 429. You sent too many requests. Wait the number of seconds in the Retry-After header and try again. See Rate limits.
Status 503. The API is switched off or cannot be reached right now. Try again later.
internal-error
Status 500. Something failed on our side, and we have a report of it. Retrying may work. If it keeps failing, contact support.
Rate limits
Each workspace member can make 2,500 requests an hour, counted across all of that member's keys, and no more than 10 in one second. A 200 answer carries three headers, and so does a 403 ip-not-allowed. No other error carries them.
| Header | Meaning |
|---|---|
X-RateLimit-Requests-Limit | Requests allowed per hour |
X-RateLimit-Requests-Remaining | Requests left in the current hour |
X-RateLimit-Requests-Reset | When the hour ends, in UTC epoch milliseconds |
curl -s -o /dev/null -D - https://attia.app/api/v1/jobs \ -H "Authorization: Bearer $ATTIA_API_KEY" | grep -i x-ratelimitx-ratelimit-requests-limit: 2500x-ratelimit-requests-remaining: 2487x-ratelimit-requests-reset: 1791637200000Over the limit, the API answers 429 with Retry-After in seconds. Wait that long before the next request. To stay under the limit, page with the largest limit you can use and poll with updated_since.
Versioning and deprecation
The version is in the path, /api/v1. Version 1 only grows. We may add an operation, a field on a response, an optional parameter or an enum value, so ignore fields you do not know. We do not remove or rename a field, change a type or make an optional parameter required. A change like that would be a version 2 at a new path.
Before an operation is retired, its answers carry a Deprecation header with the date it was deprecated and a Sunset header with the date it stops working. Log those headers in your client so a retirement does not surprise you.
The OpenAPI 3.1 description of version 1 is public, and you can generate a client from it.
curl https://attia.app/api/v1/openapi.json