Proxy
Menu

Developers

One base URL. Three credentials. No headers to guess.

The organisation, the environment, the granted scopes and the enabled services are all resolved from the credential you present. There is no organisation id to send, no organisation header to set, and no second authorisation model to learn.

Base URL
/api/v1, versioned in the path
Auth
Authorization: Bearer
Responses
JSON under a data key, stable error codes

Three credentials, because they have three jobs.

Browser code cannot hide a secret. Everything about the credential model follows from accepting that rather than working around it.

pk_test_ / pk_live_ browser

Publishable key

Identifies your integration to the browser SDK and loads the public configuration: your branding, and which components are available here.

Worth knowing

Reads nothing that belongs to your organisation. Safe to embed in a page.

sk_test_ / sk_live_ your server

Secret key

The credential every server-to-server call carries. It resolves the organisation, the environment, the scopes and the enabled services.

Worth knowing

Never reaches a browser, a log, an exception message or a webhook payload.

cs_test_ / cs_live_ browser, minted by you

Client session

Your backend trades its secret key for a token good for one capability, one resource and a few minutes, then hands that to the page.

Worth knowing

A closed list of capabilities. It cannot carry an arbitrary scope.

Upload a file, run a job, read the record.

The complete server story in two calls, the browser half in one, and the two artefacts they leave behind. The token is read from configuration, never written into code, and never sent to a page.

Choose a code sample

app/Http/Controllers/EvidenceController.php

use Proxy\ProxyClient;

// Read from configuration. Never in the repository, never in a browser.
$proxy = new ProxyClient(
    secretKey: config('services.proxy.secret'),
    organisation: config('services.proxy.organisation'),
);

$file = $proxy->storage()->upload(
    file: $request->file('evidence'),
    metadata: ['claim' => $claim->reference],
);

// A job, so this returns as soon as the work is accepted. The
// idempotency key is yours: only you know which retry is which.
$job = $proxy->processor()->analyse(
    file: $file->id,
    tasks: ['ocr', 'classify', 'extract'],
    idempotencyKey: "claim-{$claim->id}-evidence-{$file->id}",
);

$job->state;   // 'queued'

Two calls. The second returns before the work is finished.

resources/js/evidence.js

import { PoweredByProxy } from '@proxy/browser';

const proxy = new PoweredByProxy({
    publishableKey: 'pk_live_...',
    baseUrl: 'https://api.proxy.example',
});

// The uploader takes no maxFiles option. Its limits are read off the
// token your backend minted, so the page cannot widen them.
await proxy.storage.mountUploader({
    token: clientToken,
    target: '#evidence',
});

A token for one capability and one claim. A secret key never reaches the page.

GET /api/v1/processor/jobs/01k5m8f2...

{
  "data": {
    "id": "01k5m8f2r7q0x3v9tzcd4npb6h",
    "state": "completed",
    "operation": "ocr.document",
    "file": "01k5m7z4h9c1e8ptw2yb3rkq5d",
    "tasks": ["ocr", "classify", "extract"],
    "result": {
      "classification": "invoice",
      "pages": 3,
      "fields": {
        "invoice_number": "INV-40912",
        "issued_on": "2026-08-14",
        "total": "1284.60"
      }
    },
    "usage": { "quantity": 1, "unit": "document", "cost": "15p" },
    "actor": { "type": "integration", "name": "Claims portal" },
    "completed_at": "2026-08-14T09:41:07+00:00"
  }
}

The fields you asked for, the operation you were charged for, and the actor.

GET /api/v1/audit?resource=01k5m7z4...

{
  "data": [
    {
      "event": "storage.object.created",
      "resource": "01k5m7z4h9c1e8ptw2yb3rkq5d",
      "actor": { "type": "integration", "name": "Claims portal" },
      "recorded_at": "2026-08-14T09:40:58+00:00"
    },
    {
      "event": "processor.job.completed",
      "resource": "01k5m8f2r7q0x3v9tzcd4npb6h",
      "usage": { "operation": "ocr.document", "quantity": 1, "status": "charged" },
      "recorded_at": "2026-08-14T09:41:07+00:00"
    }
  ]
}

The same two calls from the evidence side. The half that matters six months later.

The one request that answers most questions

curl https://api.proxy.example/api/v1/me \
  -H "Authorization: Bearer sk_test_..."
{
  "data": {
    "credential_type": "secret",
    "environment": "test",
    "organisation": { "name": "Northgate Adjusters" },
    "integration": { "name": "Claims portal", "status": "active" },
    "scopes": ["storage:read", "storage:write", "processor:run"],
    "permissions": [
      "storage.files.view",
      "storage.files.upload",
      "storage.files.delete"
    ]
  }
}

scopes is what was granted. permissions is what those scopes confer after narrowing. They are shown separately because a scope whose service is switched off appears in the first and is still refused, and hiding that would make a misconfigured integration look correct.

Test and live are separate

Every credential is issued for one environment and the prefix says which. Test traffic never reaches a real customer, and the two sets of records do not mix.

Rotation overlaps

A new secret key works before the old one stops, so a deployment does not have to be simultaneous. Revoking one key does not disturb the others.

Additive changes ship inside v1

A new optional field, a new response field, a new endpoint or a new enum case in an open field are not breaking changes. Tolerate unknown fields and unknown enum values rather than failing on them.

The browser gets a token for one thing.

Your backend trades its secret key for a client session: one capability, one resource, a few minutes, and the constraints you set. The page receives that and nothing else.

Your backend, before the page renders

// Your backend decides what the page may do, and for how long.
$session = $proxy->clientSessions()->create(
    capability: 'storage.upload',
    resource: ['type' => 'claim', 'id' => $claim->reference],
    constraints: [
        'max_files' => 5,
        'max_file_size' => 20_000_000,
        'mime_types' => ['application/pdf', 'image/jpeg'],
    ],
);

return view('claim.evidence', ['clientToken' => $session->token]);

The capability comes from a closed list. The resource is required, so a token minted to upload against one claim cannot be replayed against another.

Your page, after it has the token

import { PoweredByProxy } from '@proxy/browser';

const proxy = new PoweredByProxy({
    publishableKey: 'pk_live_...',
    baseUrl: 'https://api.proxy.example',
});

// The uploader takes no maxFiles option. Its limits are read off the
// token your backend minted, so the page cannot widen them.
await proxy.storage.mountUploader({
    token: clientToken,
    target: '#evidence',
});

There is no maxFiles option, deliberately. The limits are read off the session your backend set, so the number shown to the person is the number Proxy will enforce.

Availability is computed, not guessed

proxy.capabilities() returns what will actually work: the integration holds the scope and the organisation is entitled to the service. A list built from scopes alone would render an uploader for a service nobody bought, and the refusal would arrive after somebody had already dragged a file onto it.

mount() refuses an unavailable component before it renders.

Origins are an allowlist

Each integration names the origins its publishable key may be used from. There is no wildcard, and the signing host keeps cross-origin access closed rather than configured.

Retries, limits, and what a webhook guarantees.

The parts of an integration that only matter once it is under load, documented before you meet them rather than after.

Idempotency
Send an Idempotency-Key on anything that costs money. Only you know which of your retries are the same logical operation. A replay answers 200 with Idempotency-Replayed: true rather than 201, so a client that retried after a timeout knows its first attempt landed.
Rate limits
Four groups: unauthenticated, read, write and expensive. Each applies two limits, one per credential and one per organisation, and the tighter one binds. A 429 carries Retry-After and the X-RateLimit-* pair.
Errors
One error shape with a stable code. Branch on the code, not on the message: the message is for a person and may be reworded, the code is part of the contract.
Not found, not forbidden
An identifier belonging to another organisation answers 404. A 403 would confirm the record exists, which is information the caller is not entitled to.

Handling a completed job

// Delivery is a record. Every attempt carries a status, and a replay
// arrives with the same event id rather than a new one.
export function handle(event) {
    if (event.type !== 'processor.job.completed') return;

    const { id, result, usage } = event.data;

    claims.attachExtraction(id, result.fields);
    ledger.note(usage.operation, usage.quantity, usage.unit);
}

A webhook event carries a delivery status through pending, processed, failed or ignored, and every attempt is recorded. Handlers should be idempotent: a replay arrives with the same event id rather than a new one.

Build against test keys first.

An organisation, an integration and a pair of test keys, before anything is enabled or metered. The reference documentation is generated from the API itself.

Request access Read the API reference

Server SDKs
PHP and JavaScript, over the same REST API
Browser SDK
Mounts real components on a client session
Versioning
In the path, with both versions served during a change