Connect: your app, acting for a business its owner connected
An API key is yours, over the businesses you opened. Connect is how your app acts for a business that already exists on BillOS and belongs to someone else: an accounting system pulling a client's ledger, a booking platform issuing its merchants' receipts, an ERP recording expenses. The owner connects the business from a consent screen; your app receives a per-business token and hears the business's events; the owner can disconnect at any time. It is OAuth 2.0, authorization code with PKCE, and it looks like Stripe Connect because that is the shape platforms already know.
Register an app
curl -X POST "$BASE/oauth-apps" -H "X-Api-Key: $KEY" -H "Content-Type: application/json" -d '{
"name": "חשבשבת בענן",
"redirectUris": ["https://app.example/billos/callback", "http://localhost:3000/callback"],
"scopes": ["businesses:read", "documents:read", "documents:write", "expenses:read", "export"],
"homepageUrl": "https://app.example", "logoUrl": "https://app.example/logo.png"
}'
# 201 → { app: { clientId: "bkapp_live_…", status: "pending", ... }, clientSecret: "bksec_live_…" }
- The app lives under your API client: your limits, your metering, your event log and your webhook endpoints serve it.
clientSecretis shown once.POST /oauth-apps/:id/rotate-secretreplaces it (the old one stops on the spot; issued tokens keep working).scopesis the most the app will ever ask for, in the fine grain (section 1).ocrcannot be requested.- Redirect URIs are matched exactly, https only, no fragments;
http://localhostandhttp://127.0.0.1are allowed for development and may vary their port. - A live app starts
pending: its name and logo are shown to business owners as something to trust, so we approve it by hand (usually within a working day; we may ask for the homepage). A sandbox app (registered with abk_test_key) is usable immediately against sandbox businesses. Everything below is identical in both.
The same app is manageable from the console (מפתחים → אפליקציות Connect) and from the terminal: billos apps create --name "…" --redirect https://app.example/billos/callback --scopes documents:read,documents:write prints the secret once and a ready authorize URL to walk the flow by hand.
The flow
1. Send the owner to authorize.
GET https://api.billos.co.il/oauth/authorize
?response_type=code
&client_id=bkapp_live_…
&redirect_uri=https://app.example/billos/callback
&scope=businesses:read documents:read documents:write export
&state=<random, bound to the user's session on your side>
&code_challenge=<BASE64URL(SHA256(code_verifier))>
&code_challenge_method=S256
state is required. PKCE is optional for a confidential client and recommended; when a challenge is sent, the exchange must carry the verifier. BillOS validates the request, then shows the consent screen on dash.billos.co.il: the owner signs in (Google, or their existing console session), picks which of their businesses to connect, reads the scopes in Hebrew, and approves or declines. A request that fails validation with a valid redirect_uri comes back to it with error=invalid_scope / invalid_request / unauthorized_client and your state; a request whose redirect_uri is not registered is refused on a BillOS page and never redirected.
2. Receive the code.
https://app.example/billos/callback?code=bkac_…&state=…
Check state. The code is valid for 10 minutes, once.
3. Exchange it.
curl -X POST https://api.billos.co.il/oauth/token \
-d grant_type=authorization_code -d code=bkac_… \
-d redirect_uri=https://app.example/billos/callback \
-d client_id=bkapp_live_… -d client_secret=bksec_live_… \
-d code_verifier=…
{ "access_token": "bkat_live_…", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "bkrt_live_…", "scope": "businesses:read documents:read documents:write export",
"business_id": "cm…", "billos_mode": "live" }
Form-encoded or JSON; client credentials in the body or as HTTP Basic. Errors follow RFC 6749 ({ "error": "invalid_grant", "error_description": "…" }, 401 for invalid_client). Store business_id: it is the business the owner picked, and every call below is nested under it.
4. Call the API with the token.
curl -H "Authorization: Bearer bkat_live_…" "$BASE/businesses/$BUSINESS_ID/documents"
The token is a credential like a key, narrowed: every route under the connected business works with the granted scopes and refuses the rest (403 missing scope: expenses:write), any other business is 404, and the client-level surface (keys, usage, apps, opening businesses) is 403: those are your key's. Webhooks and the event log are the exception, narrowed to the connected business (Events and webhooks, below). Rate limits and metering are your client's.
Nothing is exempt from the scopes, including the business itself: GET /businesses (which for a token returns exactly the one connected business, with access.via: "connect") is the profile, so it needs businesses:read, and so does the customer list. An app that only issues documents does not need either, which is why business_id comes back with the token: knowing where you are is not a read.
5. Refresh. Access tokens live an hour; refresh tokens 180 days.
curl -X POST https://api.billos.co.il/oauth/token \
-d grant_type=refresh_token -d refresh_token=bkrt_live_… \
-d client_id=bkapp_live_… -d client_secret=bksec_live_…
A refresh returns a new pair and retires the old one. For 30 seconds after that rotation the retired refresh token still works (it mints another pair), so two runs of one automation refreshing at the same moment do not end the connection. Presented again after that, a retired refresh token is the standard theft signal: every token of that connection is revoked and the owner has to reconnect. Handle invalid_grant on refresh by sending the owner through step 1 again.
Your key sees the connected business too
You do not have to route every call through per-business tokens. Once a business is connected, your own API key lists it (GET /businesses, access.via: "connect", access.app naming the app) and may call every route under it with the grant's scopes, not the key's. Store the business_id per merchant and use one credential for your whole integration, the way Stripe's platforms do. The tokens remain the standard route and the one to use when a connection must be isolated per tenant in your own storage.
Events and webhooks
Your webhook endpoints receive the connected business's events exactly as they receive your own businesses' (section 11), filtered by what the grant covers: document.*, recurring.* and quote.* need documents:read, expense.* needs expenses:read, export.* needs export, business.status_changed and ita.* need businesses:read. Two events are Connect's own and always reach your client: connection.created when an owner connects (or reconnects) a business, connection.revoked when they disconnect it or you deauthorize. Both appear in GET /events, and POST /webhooks/:id/replay covers them.
The access token can subscribe too, which is the shape an automation platform needs: each user's trigger is its own endpoint, created with that user's token rather than with your key. With a Connect token, POST /webhooks pins the endpoint to the connected business (naming another is refused), accepts only events the grant is sent anyway (asking for expense.recorded on a grant without expenses:read is a 400 that says so), and GET /webhooks, PATCH, DELETE, /test, /deliveries, /replay, GET /events and GET /events/:id all see that business's rows and nothing else. Two users connected to the same app never see each other's endpoints or events. The endpoint cap counts per business, so it bounds each user and not the whole platform. Your key still sees every endpoint your client holds (GET /webhooks?businessId= narrows it). An endpoint outlives a revoked connection but receives nothing while the connection is gone, and resumes if the owner connects your app again.
Ending a connection
- The owner, from the console (הגדרות → גישה ושיתוף → אפליקציות מחוברות) or
DELETE /businesses/:bid/connections/:id: every token for that business is refused from the next request, the business leaves your key's list, and you receiveconnection.revoked. - You:
POST /oauth/deauthorizewithbusiness_idand your client credentials ends your side;POST /oauth/revokewith atoken(RFC 7009) kills one token pair without ending the connection. - Re-consent (the owner authorizes the same app again) updates the grant to the scopes asked for this time, narrower or wider, and issues a fresh pair; it never adds silently.
- Deleting the app (
DELETE /oauth-apps/:id) revokes every connection under it.
What a connected app can never do
Connect a business it was not granted, invite people to it, connect other apps to it, change its approval or plan, mint keys, read metering. Those belong to the owner and to your key respectively, and no scope names them. Every action taken through a token is recorded in the business's audit trail under the app's name.
Sandbox
Register the app with a bk_test_ key and it is approved at once. To walk the flow, open the authorize URL while signed in to the console as the developer: the consent page offers the businesses your sandbox client opened (POST /sandbox/seed makes one), and everything that follows runs against the sandbox database with X-BillOS-Mode: sandbox on every response. A sandbox app can only connect sandbox businesses and a live app only live ones.