Background work delivered back to your own endpoint by Google Cloud Tasks, without holding a GCP credential.

### When to use it

For work too long for one request - sending a campaign to 25,000 contacts, rebuilding a report, a long import. Split it into steps that each finish well inside a minute, and have each step enqueue the next. Do **not** put anything a person is waiting on through a queue: a sign-in code or a payment receipt is sent in the request that triggers it.

### How it fits together

| Step | Who | What |
|---|---|---|
| Declare | Console | A queue: its name, the URL it delivers to, its rate and its retries. A queue that is not declared does not exist. |
| Enqueue | Your backend | `POST /tasks` with your API key - a workspace, a queue, a JSON payload, optionally a dedup `name` and a `scheduleAt`. |
| Deliver | Cloud Tasks | `POST`s `{ workspaceId, queue, payload }` to the declared URL with a Google-signed OIDC token. |
| Run | Your endpoint | Verify the token, do one step, enqueue the next, answer 2xx. Anything else is retried with backoff. |

### The rate is the application's, not a workspace's

A queue is shared by every workspace you serve, and its declared rate caps all of them together. Fairness comes from the shape of the work: a job that holds **one** task in flight at a time (do a step, enqueue the next) interleaves with every other tenant's instead of queuing behind it. Enqueuing a thousand tasks for one tenant up front is how one customer's campaign delays everyone else's.

### Deliveries are at least once

A task can arrive twice - a retry after a timeout that had in fact succeeded. Make every step safe to run again: derive ids from the step (`campaign-42-page-3`), and name the task the same way so a second enqueue of the same step answers `duplicate` rather than queuing it twice.

### Verifying a delivery

Check the bearer token's signature against Google's keys, that `email` is the Connect invoker account the platform gives you, and that `aud` is your own origin - in that order, and before reading the body. `verifyConnectTask(request)` in `@cosmoops/connect-sdk/server` does all three. An unverified request on this URL is somebody else's.

### Local development

A deployment with no task queue answers `status: "unavailable"`, and nothing is queued. Keep a fallback - a scheduled sweep that does the same step - so work still moves where Cloud Tasks cannot reach you.

## Endpoints

### POST `/api/v1/application/tasks`

Enqueue a background task

**Scope** `tasks:write` &nbsp;·&nbsp; **Rate limit** 600 requests / min per API key

Put one task on a queue your application declared in Console, for a workspace that subscribes to you. Cloud Tasks then `POST`s `{ workspaceId, queue, payload }` to the queue's declared URL, carrying a Google-signed OIDC token, at the queue's declared rate.

You choose the payload and, optionally, when - never where: the URL is the queue's, on an origin you registered. `workspaceId` in the delivery is the one this call was gated on, whatever your payload says.

#### Dedup with `name`

A second enqueue with the same `name` for the same workspace answers `status: "duplicate"` and queues nothing. Name a self-chaining step after the step itself (`campaign-42-page-3`) and two runners racing to queue it produce one task. A name stays taken for a while after its task runs, so never reuse one for different work.

#### `unavailable` is not an error

A deployment with no task queue configured (local development) answers `200` with `status: "unavailable"`. Fall back to whatever you would do without a queue.

#### Errors
`validation_error` (400 - an undeclared queue, a `scheduleAt` more than 30 days out, or a payload over 100 KB), `conflict` (409 - the queue's URL is no longer on a registered origin), `forbidden` (403 - key unknown, missing `tasks:write`, or that workspace does not subscribe to your application), `unavailable` (503 - the queue is declared but not provisioned yet; retry).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `queue` | `string` | yes |  |
| `payload` | `any` | no |  |
| `name` | `string` | no |  |
| `scheduleAt` | `date-time` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Queued, a duplicate, or unavailable here |
| `400` | validation_error |
| `403` | forbidden |
| `409` | conflict |
| `429` | rate_limited |
| `503` | unavailable |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `status` | `queued` \| `duplicate` \| `unavailable` | yes |  |
| `scheduleAt` | `any` | yes |  |