# Wayfin API v1

Wayfin coordinates tasks across organization borders: you create task
collections, invite doers by email (they need no account), and Wayfin
chases status reports, reminds, and escalates automatically. This API gives an AI agent
(or any HTTP client) read access to collections, status, risks and escalations, and
write access to create and send tasks.

Base URL: `https://www.wayfin.eu/api/v1`

## Authentication

Every request (except this document, `/api/v1` and `/api/v1/openapi.json`) needs a
bearer token:

    Authorization: Bearer wf_your_token_here

Tokens are personal API keys created by a human in Wayfin:
**Account → API keys** (`https://www.wayfin.eu/account`). The API is available on the **Pro plan**;
free accounts get `403 plan_upgrade_required`. A revoked or unknown token gets `401`.
The token grants access to the collections the account **owns** (collections where the
account is only a member are not visible in v1).

Quick check that your token works:

    curl -H "Authorization: Bearer wf_..." https://www.wayfin.eu/api/v1/me

## Quickstart

    # 1. Who am I, what plan, what quotas?
    curl -H "Authorization: Bearer $WAYFIN_TOKEN" https://www.wayfin.eu/api/v1/me

    # 2. List my task collections with status summaries
    curl -H "Authorization: Bearer $WAYFIN_TOKEN" https://www.wayfin.eu/api/v1/collections

    # 3. Status, escalations and risks of one collection
    curl -H "Authorization: Bearer $WAYFIN_TOKEN" https://www.wayfin.eu/api/v1/collections/42/status
    curl -H "Authorization: Bearer $WAYFIN_TOKEN" https://www.wayfin.eu/api/v1/collections/42/escalations
    curl -H "Authorization: Bearer $WAYFIN_TOKEN" https://www.wayfin.eu/api/v1/collections/42/risks

    # 4. Create a task (lands as a DRAFT — no email goes out yet)
    curl -X POST -H "Authorization: Bearer $WAYFIN_TOKEN" -H "Content-Type: application/json" \
      -d '{"name": "Deliver the security review", "assignee_name": "Maija Meikäläinen", \
           "assignee_email": "maija@example.org", "due_on": "2026-09-30", \
           "follow_up_starts_on": "2026-09-05"}' \
      https://www.wayfin.eu/api/v1/collections/42/tasks

    # 5. Send the invitation email to the doer (explicit, separate step)
    curl -X POST -H "Authorization: Bearer $WAYFIN_TOKEN" \
      https://www.wayfin.eu/api/v1/collections/42/tasks/117/send_invitation

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/v1` | API index and links (no auth) |
| GET | `/api/v1/docs` | This document (no auth) |
| GET | `/api/v1/openapi.json` | OpenAPI 3.1 spec (no auth) |
| GET | `/api/v1/me` | Identity, plan, quotas |
| GET | `/api/v1/collections` | Owned collections with status summaries |
| GET | `/api/v1/collections/{id}` | Collection detail incl. sections |
| GET | `/api/v1/collections/{id}/status` | Counts, verdict, attention lists |
| GET | `/api/v1/collections/{id}/escalations` | Unresolved escalations + decision-pending tasks |
| GET | `/api/v1/collections/{id}/risks` | Effective risk per task with causes |
| GET | `/api/v1/collections/{id}/tasks` | Task list (filters + pagination) |
| POST | `/api/v1/collections/{id}/tasks` | Create a task (always a draft) |
| GET | `/api/v1/collections/{id}/tasks/{task_id}` | Task detail incl. readiness |
| PATCH | `/api/v1/collections/{id}/tasks/{task_id}` | Update a not-yet-sent task |
| POST | `/api/v1/collections/{id}/tasks/{task_id}/send_invitation` | Email the invitation to the doer |

Responses embed full URLs in `links` objects — follow those instead of building paths.

## Task lifecycle

States: `draft` → `pending_acceptance` → `change_requested` → `rejected` → `active` → `pending_completion_approval` → `completed` → `halted` (not strictly linear).

- `draft` — editable, nothing sent. A draft without an assignee is a "captured" note.
- `pending_acceptance` — invitation emailed, waiting for the doer to accept/reject.
- `change_requested` / `rejected` — the doer pushed back; edit and resend.
- `active` — accepted; Wayfin now requests status reports on the task's cadence
  (`daily`, `weekly`, `biweekly`, `monthly`, `weekdays`), reminds, and escalates missed cycles.
- `pending_completion_approval` — doer reports done; the owner approves in the web app.
- `completed` — finished.

Sending is allowed from: `draft`, `change_requested`, `rejected`, `halted`. `send_invitation` is
**idempotent**: calling it on an already sent (`pending_acceptance`) task returns `200`
with the current delivery state — safe to retry after timeouts.

## Readiness — how the API tells you what is missing

Task responses include a `readiness` object:

    "readiness": {
      "ready_to_send": false,
      "state_allows_sending": true,
      "assignee_locked": false,
      "blocking_issues": [
        {"field": "due_on", "code": "missing",
         "message": "Deadline can't be blank",
         "description": "Deadline date, format YYYY-MM-DD. Required before sending. Ask the human if unknown — do not invent a deadline.",
         "example": "2026-09-30"}
      ]
    }

When `blocking_issues` is non-empty, **ask the human** for the listed values (the
`description` explains each field), PATCH them in, and only then call
`send_invitation`. Never invent email addresses or deadlines.

## Fields

| Field | Description | Example |
|---|---|---|
| `name` | Short imperative title of the task. Required always. | `"Deliver the September status report"` |
| `description` | Optional longer context for the doer. Shown on the task page they open from email. | `"Use last month's template. Focus on the integration risks."` |
| `assignee_name` | Full name of the person responsible for doing the task. Required before sending the invitation. Ask the human if unknown. | `"Maija Meikäläinen"` |
| `assignee_email` | Email address of the doer. The invitation and every follow-up goes to this address, so it must be real and correctly spelled. Ask the human if unknown — never invent an address. | `"maija@example.org"` |
| `assignee_locale` | Language for the doer's emails: fi, en, sv or de. Defaults to fi. | `"en"` |
| `due_on` | Deadline date, format YYYY-MM-DD. Required before sending. Ask the human if unknown — do not invent a deadline. | `"2026-09-30"` |
| `follow_up_starts_on` | Date (YYYY-MM-DD) when Wayfin starts asking the doer for status updates. Must not be after due_on. A few days into the work is a good default — ask the human to confirm. | `"2026-09-01"` |
| `cadence_kind` | How often status is requested once follow-up starts: daily, weekly, biweekly, monthly or weekdays. Defaults to weekly. | `"weekly"` |
| `cadence_weekdays` | Required when cadence_kind is 'weekdays': array of ISO weekday numbers (1 = Monday … 7 = Sunday). | `[1, 3, 5]` |
| `reporting_weekday` | Optional ISO weekday (1-7) the doer prefers for reporting. Usually left for the doer to choose. | `5` |
| `completion_policy` | self_complete = the doer can mark the task done; requires_approval = the owner approves completion. Defaults to self_complete. | `"self_complete"` |
| `escalation_threshold` | How many missed status cycles trigger an escalation to the owner. Defaults to the collection's setting — usually leave unset. | `2` |
| `escalation_contact_name` | Optional extra contact (e.g. the doer's manager) informed if an escalation stays unresolved. Requires escalation_contact_email. | `"Pekka Päällikkö"` |
| `escalation_contact_email` | Email of the optional escalation contact. Required if escalation_contact_name is set. | `"pekka@example.org"` |
| `invite_reason` | Optional one-sentence 'why this matters' shown to the doer in the invitation. | `"The customer go-live depends on this being ready."` |
| `section_id` | Optional id of a section (task group) inside the same collection. List sections via GET /api/v1/collections/{id}. | `12` |

## Status vocabulary

- `confidence` in status reports: `1` = very confident … `4` = unlikely to succeed on
  schedule. **Higher is worse.** Risk causes vocabulary: `schedule`, `effort`, `dependencies`, `scope`, `quality`, `other`.
- Collection `verdict.state`: `green` (on track), `amber` (overdue tasks or elevated
  risk), `red` (unresolved escalations, decision pending or an explicit action note),
  `gray` (too little response signal to trust the picture).
- `effective_risk` per task (1–4): escalated or decision-pending → 4; overdue → at
  least 3; otherwise the latest reported confidence; silent-but-followed → 2.
- Attention phases in `/status`: `request` (asked, no answer yet), `reminder`
  (reminders sent), `escalated` (escalation open).

## Errors

All errors share one shape:

    {"error": {"code": "validation_failed", "message": "…", "status": 422,
               "errors": [{"field": "…", "code": "missing", "message": "…",
                           "description": "…", "example": "…"}],
               "hint": "…", "retryable": false, "docs_url": "…"}}

| code | HTTP | Meaning | What to do |
|---|---|---|---|
| `unauthorized` | 401 | Missing/invalid/revoked token | Check the Authorization header; ask the human for a new key |
| `plan_upgrade_required` | 403 | Account is not on Pro | Human decision — do not retry |
| `email_verification_required` | 403 | Account email not verified | Human must click the verification link — do not retry |
| `task_quota_exceeded` | 403 | Tracked-task quota full | Human decision: finish tasks or upgrade |
| `not_found` | 404 | Id doesn't exist or isn't owned by this account | List collections to see what exists |
| `validation_failed` | 400/422 | Missing or invalid fields | Fix per-field `errors`; ask the human for unknowns |
| `invalid_state` | 409 | Operation not allowed in the current state | Read `current_state`/`allowed_states`; GET the task |
| `stale_resource` | 409 | Task changed since you read it | GET again, re-apply, retry with fresh `expected_updated_at` |
| `idempotency_key_conflict` | 409 | Key was used for a different task payload | Use a fresh key for the different logical request |
| `idempotency_in_progress` | 409 | Original request with this key is still running | Retry with the same key in a few seconds |
| `rate_limited` | 429 | Too many requests | Wait `Retry-After` seconds, then retry |
| `server_error` | 500 | Unexpected error | Retry once after a moment |

`retryable: true` means an automatic retry may succeed; `false` means a human action
or a different request is needed — do not hot-loop.

## Pagination

Only the task list paginates: `?limit=50` (1–100) and `?after_id=<last seen id>`.
Order is always `id` ascending. The response's `pagination` object carries `has_more`
and `next_after_id` (null when done). Keep filters identical across pages.
Non-integer or out-of-range values are a `400 validation_failed`, never silently corrected.

## Rate limits

Per token: 300 reads / 5 min, 60 writes / 10 min (plus a per-account write cap of
120 / 10 min across all its tokens). Windows are fixed and epoch-aligned. Every
authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
`X-RateLimit-Reset` (Unix seconds) for the currently tightest applicable window —
pace yourself before remaining hits zero. On `429`, `Retry-After` is the number of
seconds until that window has actually reset; waiting that long is enough.

## Writes and email — safety model

Creating a task never emails anyone. `send_invitation` emails a real person on the
account's behalf, so it is a separate, explicit call with an honest
`invitation.delivery_state` in the response. Possible values (full set:
`simulated`, `held`, `bundled`, `pending`, `sending`, `sent`, `delivered`, `bounced`, `complained`, `failed`, `suppressed` plus `unknown`), the ones worth acting on:
`pending`/`sending`/`sent` (on its way), `delivered` (accepted by the recipient's
server), `held` (briefly queued so messages to the same person can be bundled;
delivers automatically), `bundled` (already delivered inside a combined digest —
no separate email will arrive), `simulated` (non-production environment, no real
email), `suppressed`/`bounced`/`failed`/`complained` (will NOT be delivered —
verify the address with the human), `unknown` (no delivery record found — check the
web outbox). The `invitation.note` field explains the state in plain language.
Duplicate protection: send an `Idempotency-Key` header (for example, a UUID) on
`POST` task creation. Retries with the same key and payload return the original
result and include `Idempotency-Replayed: true`; use one key for one logical request.

Quota note: the free plan tracks at most 20
tasks; Pro is unlimited. Resending from `change_requested` does not consume a new
quota slot; resending from `rejected` does.

## Data protection

Task and report payloads contain personal data of the account's collaborators (names,
email addresses, free-text comments). The account owner is the data controller for
whatever you do with this data downstream — handle it accordingly and only feed it to
systems the human has approved.

## Versioning

`v1` is a stable contract: fields may be **added** without notice, but nothing is
removed or repurposed within v1. A breaking change would ship as `/api/v2` with at
least 6 months of parallel v1 availability, announced via `Deprecation` and `Sunset`
headers on v1 responses.
