<!-- title: Authentication | order: 3 | summary: Exchange a pre-registered client for an org-scoped api_key, then send it with Partly-Integration-ID on every call. -->

# Authentication

> Rails Sandbox is a Rails-compatible API — a faithful mock of the public `2026-01` Integrations API, plus clearly-labelled proposed extensions. It is not vendor documentation.

Every authenticated call to the Rails Sandbox carries two headers: an `Authorization: Bearer <api_key>` and a `Partly-Integration-ID: <integration_id>`. You obtain that pair once, by exchanging a pre-registered OAuth client through the install endpoint. This page covers the install model, the two headers, the org/scope model, and every coded auth failure with its fix.

If you just want a working call in under a minute, this sandbox pre-loads ready-to-use credentials so you can skip the install entirely — see [Quickstart](./quickstart.md). The install flow below is documented for fidelity to the contract; you do not have to run it.

## Base URL and RPC convention

All examples target the deployed mock:

```
https://partifact-mock-rails.thanhvuttv.workers.dev
```

Every method is an RPC over `POST`:

```
POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/<dotted.method>
```

The path prefix is exactly `/api/2026-01/`, and the dotted method name is the final path segment (for example `.../api/2026-01/integrations.insert`). The request body is JSON; an empty body is treated as `{}`. See the [API Reference](./api-reference.md) for the full method inventory and [/openapi.json](/openapi.json) for the machine-readable spec.

## The install model

`integrations.insert` is the **only** unauthenticated method in the entire API. It bootstraps the credential everything else needs, so it cannot itself require one. You call it with a pre-registered OAuth client and an access code; it returns a long-lived, org-scoped credential pair.

**Request body** — three fields, all strings:

| field | meaning |
|---|---|
| `client_id` | The pre-registered OAuth client identifier. |
| `client_secret` | The client's secret, compared in constant time. |
| `access_code` | A single-use authorization code (~15-minute TTL). |

**Response** — the credential you store and reuse:

```json
{
  "api_key": "partly_<32-hex-uuid>",
  "integration_id": "<uuid>"
}
```

The `api_key` is freshly minted (prefix `partly_` followed by a UUID with dashes stripped) and the `integration_id` is a fresh UUID. Both are long-lived: install once, then reuse the pair on every subsequent call. There is no token refresh in this contract.

> **The access code is single-use.** A successful install consumes the code. Re-running install with the same `access_code` then fails `invalid_access_code` (401). To run the flow twice you need a fresh code.

### Pre-registered demo client

Real vendor onboarding is a **manual** process — there is no self-serve credential issuance, and you cannot mint your own OAuth client. We label this honestly: in production you would be provisioned a `client_id` / `client_secret` out of band. In this sandbox, one demo client is pre-registered so the install flow is runnable end to end:

| field | value |
|---|---|
| `client_id` | `partly_client_demo` |
| `client_secret` | `secret_demo_8f3a` |
| `access_code` | `ac_demo_valid_15m` (single-use) |

This client is provisioned for the **repairer** organization (Canterbury Collision Group).

### Isolated sandboxes — `x-partifact-tenant`

Because the demo `access_code` is **single-use**, the install flow only works once per world. To get your own fresh world — with an unconsumed access code — send an `x-partifact-tenant: <name>` header on every call. Each distinct value is a private copy of the sandbox, seeded identically and self-healing after an hour idle; changing the name is a zero-cost reset. This header is a **sandbox convenience for isolation**, not part of the contract (a real the tenant boundary is the organization). The [Quickstart](./quickstart.md) uses it throughout.

### curl — a successful install

```bash
curl -sS -X POST \
  https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/integrations.insert \
  -H 'x-partifact-tenant: my-sandbox' \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "partly_client_demo",
    "client_secret": "secret_demo_8f3a",
    "access_code": "ac_demo_valid_15m"
  }'
```

Response (HTTP `200`; your values will differ — the credential is freshly minted):

```json
{
  "api_key": "partly_3f2a9c7d1e0b4a86b5c2d4e6f8091a2b",
  "integration_id": "7b1e2c3d-4a5f-4061-8b9c-0d1e2f3a4b5c"
}
```

> **If you run this against the live deploy and the demo `access_code` has already been consumed**, you will get `invalid_access_code` (401). That is expected — the code is single-use. Use the [pre-loaded credentials](./quickstart.md) instead, which never require the install dance.

## The two required headers (on every other call)

Every method except `integrations.insert` requires both headers. Names are case-insensitive on the wire, but the canonical casing per the contract is:

```
Authorization: Bearer <api_key>
Partly-Integration-ID: <integration_id>
```

Notes:

- The `Bearer ` scheme prefix is **required** on the `Authorization` header (the token is parsed by `/^Bearer\s+(.+)$/i`). A bare key with no `Bearer ` prefix does not authenticate.
- Both headers must be present and must resolve as a known `(api_key, integration_id)` pair. If either is missing, unparseable, or the pair is unknown, the call fails `unauthorized` (401).

A minimal authenticated call (reads the seeded job):

```bash
curl -sS -X POST \
  https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/repairer.jobs.get \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2' \
  -H 'Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001' \
  -d '{ "identity": { "external": "CCC-2026-04817" } }'
```

## Org and scope model

A credential is bound to one organization and carries one **scope** — a role that gates which methods it may call:

- **`repairer`** drives the job: open the repair job, identify parts, fetch the basket, place a procurement.
- **`supplier`** confirms: only a supplier-scope credential may call `supplier.procurements.confirm`.

Parts procurement is an **inherently two-party protocol** — a repairer requests, a supplier confirms. In a real deployment these are two separate integrations on two separate organizations. To make the full lifecycle runnable by a single caller, **this sandbox pre-loads BOTH credentials**, so you can drive both sides by swapping the two header pairs:

| scope | org | `api_key` | `integration_id` |
|---|---|---|---|
| `repairer` | Canterbury Collision Group | `partly_demo_repairer_3f8a1c0d9e2b4a67b1c2` | `0c000000-0000-4000-8000-000000000001` |
| `supplier` | Christchurch Toyota — Parts | `partly_demo_supplier_8b4e2f1a6c0d3e9f7a25` | `0c000000-0000-4000-8000-000000000002` |

> **Simplification, labelled.** Bundling both sides into one sandbox is a demo convenience, not the production model. Real API integrations hold exactly one role. These two seed keys carry a `partly_demo_...` prefix and are distinct from the `partly_<uuid>` keys that `integrations.insert` mints.

The supplier credential here is for S1, **Christchurch Toyota — Parts**. This sandbox holds only **one** supplier credential, so only orders placed with S1 are confirmable — see [Errors](./errors.md) and [Webhooks](./webhooks.md) for how that constraint surfaces.

## Coded auth and install failures

Each variant is a type-tagged JSON body and a matching HTTP status. (The HTTP status is mock realism; the body's `type` is the contract signal.) The full error model — including the one method that returns bare-string variants — lives in [Errors](./errors.md).

### Cross-cutting (any authenticated method)

| code | HTTP | body | meaning | fix |
|---|---|---|---|---|
| `unauthorized` | `401` | `{ "type": "unauthorized" }` | A required header is missing or unparseable, or the `(api_key, integration_id)` pair is unknown. A configuration issue, not a workflow step. | Send both headers with a valid pair; include the `Bearer ` prefix. Do not blindly retry — fix the credential first. |
| `forbidden` | `403` | `{ "type": "forbidden" }` | The credential is valid but its scope is wrong for this method (right key, wrong role). | Use the credential whose scope matches the method — `supplier` for `supplier.procurements.confirm`, `repairer` for repairer methods. |

### Install-specific (`integrations.insert`)

| code | HTTP | body | meaning | fix |
|---|---|---|---|---|
| `integration_not_found` | `404` | `{ "type": "integration_not_found" }` | `client_id` matches no registered OAuth client. | Check the `client_id`. In the sandbox, use `partly_client_demo`. |
| `o_auth_not_supported_by_integration` | `403` | `{ "type": "o_auth_not_supported_by_integration" }` | The client exists but is not enabled for the OAuth install flow. | Use a client provisioned for OAuth install (the demo client `partly_client_demo` is). |
| `invalid_client_secret` | `401` | `{ "type": "invalid_client_secret" }` | `client_secret` does not match `client_id` (constant-time compare). | Re-check the secret. In the sandbox, `secret_demo_8f3a` pairs with `partly_client_demo`. |
| `invalid_access_code` | `401` | `{ "type": "invalid_access_code" }` | The `access_code` is unknown, expired (~15-min TTL), or already consumed (it is single-use). | Obtain a fresh access code. In the sandbox, if `ac_demo_valid_15m` is spent, use the [pre-loaded credentials](./quickstart.md) and skip install. |

> There is no `invalid_client` code. The "client exists but cannot do OAuth install" case is named `o_auth_not_supported_by_integration` (403), exercised in the sandbox by the `partly_client_nooauth` client.

### One failing example

A wrong secret against a valid client:

```bash
curl -sS -i -X POST \
  https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/integrations.insert \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "partly_client_demo",
    "client_secret": "wrong_secret",
    "access_code": "ac_demo_valid_15m"
  }'
```

Response:

```
HTTP/1.1 401 Unauthorized
```

```json
{ "type": "invalid_client_secret" }
```

The fix is in the body: the secret does not match the client. Send `secret_demo_8f3a` for `partly_client_demo`. No coded auth or install error is ever resolved by a blind retry — each one names a specific input to correct.

## Next

- [Quickstart](./quickstart.md) — run an authenticated call using the pre-loaded credentials, no install required.
- [Errors](./errors.md) — the full error model, the bare-string method, and how the MCP layer enriches each code with a next step.
- Machine-readable: [/openapi.json](/openapi.json), [/llms.txt](/llms.txt), [/llms-full.txt](/llms-full.txt).
