Bearer tokens
Every API request must include a bearer token in the Authorization header:
Authorization: Bearer stp_test_abc123...
Tokens are managed from Settings → API Tokens in the web UI. Create as many as you need — one per integration, per environment, per deployment stage.
Test vs live tokens
Tokens come in two flavours, distinguished by prefix:
| Prefix | Environment | What it can do |
|---|---|---|
stp_test_... |
Test / sandbox | Read and write employers with environment: "evte". Submissions go to ATO EVTE, not production. |
stp_live_... |
Live / production | Read and write employers with environment: "production". Submissions go to ATO production. |
A test token cannot see or modify live data, and vice versa. This is enforced at the database query level, not just the UI — an accidentally-leaked test token cannot trigger real ATO submissions, and a leaked live token won't collide with sandbox data.
The same base URL is used for both (https://stp.beeswaxapp.com/api/v1/). Only the token determines which environment you're talking to.
Creating a token
- Sign in → Settings → API Tokens
- Pick Test or Live
- Enter a memorable name (e.g. "Production Beeswax integration")
- Optionally set an expiry date — useful for rotating credentials
- Select scopes — the minimum set of capabilities this token needs
- Click Generate token
The full token value is shown exactly once, immediately after creation. Copy it to your secrets store right away. We only keep a hash, so there's no way to retrieve it later — if you lose it, revoke the old one and create a new one.
Scopes
Tokens can be scoped to limit what they can do. A CI script that only needs to read pay event status should not need write access to employer records.
| Scope | What it allows |
|---|---|
employers:read |
GET /employers, /employers/:id |
employers:write |
POST /employers, PATCH /employers/:id |
employees:read |
GET /employers/:id/employees and individual employees |
employees:write |
POST and PATCH employee records |
pay_events:read |
GET /pay_events and individual pay events |
pay_events:write |
POST, PATCH, mark-ready, submit, item operations |
submissions:read |
GET /submissions |
submissions:write |
Retry a rejected or errored submission, cancel in-flight lodgements |
webhooks:read |
List webhook endpoints and recent deliveries |
webhooks:write |
Create, update, rotate the signing secret on, and delete webhook endpoints |
ytd:read |
GET /ytd summary |
platform:act_on_behalf |
Platform-tier: act across many downstream customer employers on a single token (see Platform tokens below) |
If a token doesn't have the required scope for an endpoint, the response is 403 Forbidden:
{
"error": "Forbidden",
"message": "This token does not have the 'pay_events:write' scope"
}
Expiry
Tokens can have an optional expiry date. Expired tokens return 401 Unauthorized:
{
"error": "Unauthorized",
"message": "Invalid or expired API token"
}
For long-lived production integrations, leave the expiry blank. For CI/CD or temporary work, set one explicitly.
Rotation
To rotate a token:
- Create a new token with the same scopes
- Deploy the new token to your server
- Verify it's working in your logs (check
last_used_atin the UI) - Revoke the old token
The new token's prefix (stp_test_ vs stp_live_) must match the environment of the integration it's replacing. You can't swap a test token for a live token mid-deployment.
Revoking a token
On the API Tokens page, click Revoke next to any token. Revocation is instant — the next request using the token returns 401. Revoked tokens are kept in the audit log but hidden from the active list by default.
Security best practices
- Treat tokens like passwords. Never commit them to git, even in test files. Use environment variables or a secrets manager.
- Scope tightly. Don't give a read-only integration
employers:write. Scope creep leads to blast radius. - Use test tokens for CI. If your CI leaks a test token, the damage is zero — nothing can reach production from it.
- Rotate regularly. Annual rotation for production tokens is a reasonable cadence. Rotate immediately if you suspect compromise.
- Monitor
last_used_at. If a token hasn't been used in a while, revoke it. - Set expiry on temporary tokens. Contractors, one-off migrations, and debugging sessions should all use expiring tokens.
- Never log tokens. Redact
Authorizationheaders in your application logs. - Use HTTPS. The API only accepts HTTPS; we reject HTTP at the proxy layer.
Browser (CORS) access
Each token carries an allowed origins whitelist (newline- or comma-separated). If you're calling the API directly from a browser — e.g. a customer-facing dashboard built on top of our lodgement — add every origin that'll send credentialed requests:
https://dashboard.fintech.example
https://staging.fintech.example
When the whitelist is empty, the token is server-to-server only — we emit no Access-Control-Allow-Origin header and the browser's CORS check fails. This is the secure default.
Entries must be exact-match origins (scheme + host + port). * is accepted if you really want to wildcard — we'll echo back whatever origin is on the request — but do this only for public unauthenticated data.
Preflight OPTIONS requests are answered permissively so the real request can reach the auth check; that's normal for this pattern.
Request signing (optional)
For high-trust integrations (neobanks, PSPs) you can turn on HMAC request signing per-token. When enabled, every request must carry two extra headers:
X-STP-Timestamp: 1712659200
X-STP-Signature: 5d41402abc4b2a76b9719d911017c592f3a8a8d5e...
Where X-STP-Signature is:
HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}").hexdigest
The canonical string is literally "{timestamp}.{raw_body}" — the Unix timestamp, a single ASCII period, then the exact bytes of the request body as sent on the wire. Do not re-serialise, pretty-print, or sort JSON keys before signing; sign whatever you're about to transmit and send those same bytes.
For GET, DELETE, and any other bodyless request, raw_body is the empty string — the canonical string is just "{timestamp}." (timestamp, period, nothing after). This is the single most common source of signature_invalid errors; if you see that response on a read request, this is almost certainly why.
The timestamp must be within ±5 minutes of server time — older signatures are rejected as replay attempts.
Enable signing by setting Require signing on the token and rotating to get a signing secret. If the secret is ever rotated, shift traffic atomically — there's no dual-secret window.
require "openssl"
ts = Time.now.to_i
body = '{"employer":{"legal_name":"Acme"...}}'
sig = OpenSSL::HMAC.hexdigest("SHA256", ENV["STP_SIGNING_SECRET"], "#{ts}.#{body}")
Net::HTTP.post(
URI("https://stp.beeswaxapp.com/api/v1/employers"),
body,
"Authorization" => "Bearer #{ENV["STP_TOKEN"]}",
"Content-Type" => "application/json",
"X-STP-Timestamp" => ts.to_s,
"X-STP-Signature" => sig
)
Signing is on top of Bearer auth, not instead — an unsigned request with a valid token gets 401 if the token has signing required, and a signed request with an invalid token still gets 401 for bad auth.
Platform tokens (acting on behalf of a downstream employer)
Most integrations have one token per employer: the token owner is the business. That's the default and the simplest model.
Some integrations sit one layer higher — a platform product (e.g. a fintech, a neobank, a payroll SaaS) calls the API on behalf of many downstream customer employers using a single token. When one token acts across many customers, our audit log needs to know which specific customer and which specific human triggered each request — that's the ATO OSF Control 1 (unique-user attribution) requirement.
Platform tokens solve this with two extra request headers.
Issuing a platform token
Grant the platform:act_on_behalf scope when you create the token. This scope is additive — combine it with the normal resource scopes (employers:write, pay_events:write, etc.) the platform needs.
Using a platform token
Every call must include both headers:
Authorization: Bearer stp_live_platform_abc123...
X-On-Behalf-Of-Employer: 4821
X-Acting-User: jane@acme.com.au
| Header | Meaning |
|---|---|
X-On-Behalf-Of-Employer |
Integer ID of the customer employer this request is acting for. Must be an employer provisioned under the platform token's account. |
X-Acting-User |
Free-form identifier of the human who triggered the request — email is cleanest. The platform is contractually responsible for populating this honestly. |
The employer's environment (test/live) must match the token's. A live platform token cannot act against an evte employer and vice versa.
What gets enforced
- The request scope narrows to the named employer — the platform can't accidentally read or write a sibling customer's data, even though the same token owns both.
- Every audit log row stamps the platform token, the on-behalf-of employer, and the acting user. An ATO auditor asking "who lodged this pay event?" gets
jane@acme.com.au at Acme Pty Ltd (employer 4821) via beeswax (token id 17).
Error cases
| Response | Cause |
|---|---|
403 Forbidden with code on_behalf_of_not_permitted |
A token that doesn't have platform:act_on_behalf sent one of the headers. Stops a leaked regular token from forging attribution. |
400 Bad Request with code on_behalf_of_required |
Platform token was used without both headers. Both are mandatory — there's no "default" employer. |
403 Forbidden with code on_behalf_of_employer_unknown |
Employer ID doesn't exist under this token's account, or its environment doesn't match. |
When not to use platform tokens
If your product is just one company's own integration (single ABN, single platform), use a regular per-employer token. Platform tokens exist for the explicit multi-customer case.
Error responses
| Code | When |
|---|---|
401 Unauthorized |
Missing Authorization header, invalid token, expired token, revoked token, missing/invalid X-STP-Signature on a token with signing required |
402 Payment Required |
Token is valid but the account isn't on the API plan (or is past the payment grace period) |
403 Forbidden |
Token is valid but lacks the scope required for this endpoint, or on-behalf-of headers were sent/refused |
400 Bad Request |
Platform token called without both on-behalf-of headers |
429 Too Many Requests |
Token is valid but has exceeded rate limits — see Rate limits |