Developers

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.

api-unavailable

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.

HeaderMeaning
X-RateLimit-Requests-LimitRequests allowed per hour
X-RateLimit-Requests-RemainingRequests left in the current hour
X-RateLimit-Requests-ResetWhen 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-ratelimit
x-ratelimit-requests-limit: 2500x-ratelimit-requests-remaining: 2487x-ratelimit-requests-reset: 1791637200000

Over 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