Proxy
Menu

Developers

A clean API underneath every product.

Everything a customer sees in Proxy is built on the same REST API, server and browser SDKs, embeddable components and webhooks your team can use. The organisation, the environment, the granted scopes and the enabled services are all resolved from the credential you present: no organisation id to send, 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

Eight services that behave like one platform.

Each product is delivered by one or more services, and every service does these six things the same way. Independence with six different opinions about authentication would be worse than a suite.

One credential model

A publishable key for the browser, a secret key for your server, and short-lived client sessions your backend mints for a single capability.

Three types, one prefix convention, and the environment in the prefix.

One authorisation model

The same policies serve the API and the web interface. A permission written for a person is the permission an integration inherits, narrowed by its scopes.

There is no second authorisation system to keep in step with the first.

One organisation, resolved for you

The credential decides the organisation, the environment and the enabled services. There is no organisation id to send and no header to set.

A cross-organisation identifier is refused during route resolution.

One meter, read per service

Every operation writes a usage record carrying its operation key, quantity, billing unit and cost, so "what did Processor cost this month" is a query rather than an allocation exercise.

Free operations are recorded in full, because the count still matters.

One set of wire conventions

A versioned path, responses under a data key, stable error codes, idempotency keys on anything that costs money, and rate limits applied per credential and per organisation.

Additive changes ship inside v1. Breaking ones get a new version.

One audit trail

Every mutating operation has a named actor, and that actor is written to the trail and onto the record. Services do not keep separate histories that have to be reconciled.

The actor is required, not derived, and never optional.

API documentation (opens a different site) Service reference

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 test keys 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