Publishable key
Identifies your integration to the browser SDK and loads the public configuration: your branding, and which components are available here.
Developers
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.
Browser code cannot hide a secret. Everything about the credential model follows from accepting that rather than working around it.
Identifies your integration to the browser SDK and loads the public configuration: your branding, and which components are available here.
The credential every server-to-server call carries. It resolves the organisation, the environment, the scopes and the enabled services.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The parts of an integration that only matter once it is under load, documented before you meet them rather than after.
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.
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.