Authentication
Rails Sandbox is a Rails-compatible API — a faithful mock of the public
2026-01Integrations 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. 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 for the full method inventory and /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:
{
"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_codethen failsinvalid_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 uses it throughout.
curl — a successful install
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):
{
"api_key": "partly_3f2a9c7d1e0b4a86b5c2d4e6f8091a2b",
"integration_id": "7b1e2c3d-4a5f-4061-8b9c-0d1e2f3a4b5c"
}
If you run this against the live deploy and the demo
access_codehas already been consumed, you will getinvalid_access_code(401). That is expected — the code is single-use. Use the pre-loaded credentials 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
Bearerscheme prefix is required on theAuthorizationheader (the token is parsed by/^Bearer\s+(.+)$/i). A bare key with noBearerprefix 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 failsunauthorized(401).
A minimal authenticated call (reads the seeded job):
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:
repairerdrives the job: open the repair job, identify parts, fetch the basket, place a procurement.supplierconfirms: only a supplier-scope credential may callsupplier.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 thepartly_<uuid>keys thatintegrations.insertmints.
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 and Webhooks 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.
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 and skip install. |
There is no
invalid_clientcode. The "client exists but cannot do OAuth install" case is namedo_auth_not_supported_by_integration(403), exercised in the sandbox by thepartly_client_nooauthclient.
One failing example
A wrong secret against a valid client:
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
{ "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 — run an authenticated call using the pre-loaded credentials, no install required.
- Errors — the full error model, the bare-string method, and how the MCP layer enriches each code with a next step.
- Machine-readable: /openapi.json, /llms.txt, /llms-full.txt.