Security
How the partner API is secured, what each control actually enforces, and what we deliberately do not do. If you are filling in a security questionnaire, the vocabulary table at the end maps the usual terms onto what we call them.
The credential#
One header, one secret. The complete API key is the credential — there is nothing to sign and no token exchange:
curl https://api.avvio.xyz/business/api/v1/payments/organizations/{orgId}/balance \
-H "x-api-key: avvio_live_…"We store a SHA-256 hash of the complete key and its public lookup prefix. The key itself is never persisted and never logged, which is why we show it once at issuance and cannot show it again. SHA-256 rather than a password KDF is deliberate: the secret is 256 random bits, so there is no dictionary to slow down.
A key is bound to one organization and is refused on any other's routes.
Limiting what a leaked key can do#
Three settings are fixed when a key is issued. All three are enforced on every request, server-side, and all three survive rotation.
| Control | Also called | What it enforces |
|---|---|---|
Scopes — read / write |
Scoped tokens, least privilege | A key scoped read is refused any method but GET, and refused full account details even on a GET |
| Allowed source addresses | IP allowlisting, IP pinning | A request from any other address is refused with KEY_IP_NOT_ALLOWED, not silently permitted |
| Expiry | Credential lifetime | 365 days by default, 730 at most. The key dies without anyone having to chase it |
Scopes are coarse on purpose: a key either reads or it writes. There is no per-resource or per-route scope — no "may create payouts but not touch beneficiaries". A permission matrix is a thing to get subtly wrong, and "reads only" is a promise you can verify yourself by reading the HTTP method. If you need a finer boundary today, the boundary is a separate organization, not a separate scope.
A read-only key is the cheapest exposure reduction available to you, because reconciliation jobs, monitoring probes and dashboards are exactly where a credential tends to spread.
Scopes are not the outer boundary — the route allowlist is. A key authenticates as a machine member of your organization, so the guard is default-deny: a perfectly valid key is refused on every route that has not explicitly opted in, and refused before we so much as look it up. Only the payout surface is opted in. Team management, KYB documents, provider terms and wallet operations are not reachable with a key at all, whatever its scope — so the question "what could this credential touch?" is answered by the endpoint list in this documentation, not by a permission matrix you have to reason about.
Source addresses must be exact. A CIDR block is not matched, so a key pinned to one would refuse every request; issuance rejects a range rather than letting you find out in production.
All three are fixed at issuance. There is no endpoint that edits a live key's scopes, addresses or expiry, and rotation carries them forward unchanged rather than reopening them. Changing any of them means revoking the key and issuing a new one — so decide them when you issue, and issue a second key rather than widening the first.
Rotation#
POST /api-keys/{id}/rotate mints a successor and gives the predecessor a deadline — 24 hours by default — rather than killing it immediately. Both work during the overlap, so there is no window where neither does. Rotation that causes an outage is rotation nobody performs.
The successor carries the predecessor's scopes, allowed addresses and lifetime. Rotation renews a credential; it never widens one. A read-only key cannot become a spending key by being rotated, and a key you deliberately issued for thirty days comes back as another thirty-day key rather than a yearly one.
Lifetime, not remaining time: the successor starts its predecessor's full span again. A successor inheriting only what was left would shrink on every rotation until a key expired inside its own overlap window.
Three things rotation deliberately refuses:
An expired key. Otherwise "rotate" means "resurrect" — the dead key would come back for the whole overlap window, which is the state expiry exists to end.
- A revoked key. Same reason.
A second successor. One successor per predecessor, so a double-click or a retry after a timeout returns
KEY_ALREADY_ROTATEDand names the successor that already exists, instead of quietly minting a second live credential.
Issuing, rotating and revoking are human actions in the dashboard. A key cannot mint, list, or revoke another key — including itself. A stolen credential cannot be used to manufacture more.
And a key cannot approve its own spend. The machine member a key authenticates as holds the operator role: inside the set allowed to *propose* money movement, and deliberately outside the set of treasury signers. Where your organization requires M-of-N approval on a payment, the approving signatures come from owner and admin members holding their own passkeys — a credential cannot be both the thing that requests a payment and one of the things that authorizes it. Accepting provider terms and changing team membership sit outside the same boundary.
Environment separation#
avvio_test_ and avvio_live_ are separate rows resolving to separate organizations. The environment is read from the credential's own prefix, never from a database column, so a stale row can never promote a test key to live money. A test key addresses the same organization id as your live key and resolves server-side to that organization's sandbox — the only difference between staging and production is the value of one variable.
Revocation and audit#
Revocation is immediate. Every key carries an event trail — issued, rotated, revoked — with the actor responsible and, on a rotation, the successor's id. Expiry is passive and records no event: the key simply stops working when its expiresAt passes.
If a key may have been exposed, revoke it in the dashboard and issue a successor, then tell us at security@avvio.xyz. We would rather hear about a false alarm than find out later.
Data protection#
The API is served over HTTPS.
Personal data is encrypted at rest at the field level, not merely on an encrypted volume. Each protected field is sealed with AES-256-GCM under a data key wrapped by AWS KMS, and the ciphertext is bound with additional authenticated data to the row that owns it — so a ciphertext lifted from one record does not decrypt against another.
What that covers on your integration:
| Encrypted | Holds |
|---|---|
RecipientPaymentMethod.details |
The full destination account — CLABE, IBAN, account and routing numbers |
| KYB associated persons | Tax id, government id number, date of birth, email, phone, address |
| KYC profiles | Tax id, phone, address |
| Custom bank accounts | Account number, ABA routing number, nostro account |
This table is the whole of it, and the boundary matters. Field-level encryption covers the fields listed above, not every field in our database. Data we receive back from a payment network — the account record it returns after we register a beneficiary, for instance — is retained as that network sent it. Ordinary contact fields on your organization and its members are stored as written. If a specific field matters for your review, ask and we will tell you which side of the line it is on rather than leaving you to infer it from a table.
Two consequences worth knowing. Listings return a last4 rather than the full account, which is what reconciliation actually needs; reading the complete details is a separate call that a read-only key is refused. And secrets we mint for you are returned exactly once at creation and are never retrievable through the API afterwards — but for different reasons, and the difference is worth stating.
The API key we genuinely cannot return: we store only a SHA-256 hash, so there is nothing to give back. The webhook signing secret we can read, because signing your deliveries requires it; what protects it is that no endpoint returns it and no key can ask. If your threat model distinguishes "cannot be disclosed" from "is not disclosed", those two secrets sit on different sides of that line.
Webhook authenticity#
Deliveries are signed with Standard Webhooks (HMAC-SHA256), so you can verify with an off-the-shelf library rather than against a format only we speak. Verify over the raw bytes before parsing, and dedupe on svix-id. See Webhooks.
A webhook is a nudge, never the record. Build reconciliation against the API.
Where we will deliver. An endpoint URL must be HTTPS and must not carry credentials in the URL. The destination address is resolved when the endpoint is registered and pinned for the connection itself, so a hostname that resolves one way for the safety check and another way at connect time cannot be used to point a delivery somewhere it was not allowed. Deliveries time out after 10 seconds. Plain HTTP is accepted only against literal loopback, only in the sandbox, so your first receiver can be a script on your laptop.
The signature header carries a space-separated list of values, so a secret rotation can present two valid signatures on one request — verify against any match rather than assuming a single value.
Dashboard access#
Issuing, rotating and revoking keys are human actions, so the dashboard is part of your attack surface and is worth stating.
Sign-in is passwordless: authentication is a passkey (WebAuthn), held in your authenticator and bound to the origin. There is no password to phish, reuse, or find in a breach dump, and no password reset flow to social-engineer. A team member cannot be added without registering one.
Membership carries a role — owner, admin, operator or viewer — enforced server-side on the routes that declare one, not in the interface.
A dashboard session is not a bearer token. A stolen session alone is not enough to move money or touch a credential: requests that mutate are signed in the browser with the session's own key, over a canonical form of the request — method, path, body, idempotency key, timestamp and a nonce — and we verify that signature server-side. The timestamp must be fresh and the nonce is single-use, enforced by a uniqueness constraint rather than a cache, so a captured request cannot be replayed even within its freshness window.
This is the one place we do require request signing, and it is why the partner API not requiring it is a deliberate trade rather than an oversight: a browser can hold a signing key in an authenticator, and a partner's server-to-server integration would have had to build one.
Rate limiting#
100 requests per minute per credential by default, 600 on high-volume payout and reconciliation routes, and a 2,000-per-minute source-address ceiling that always applies regardless of the credential presented. Limits and Retry-After arrive in the response headers — read them rather than guessing. See Going Live.
What we do not do#
Stated plainly, because a security review should not have to infer it from silence:
Authentication and authorization
No OAuth 2.0, and no OpenID Connect. This is a server-to-server API with no end-user consent step to model and no third party acting on a user's behalf, which is the problem OAuth exists to solve. Authentication is the API key described above. There is no authorization code flow, no client credentials grant, and no bearer token endpoint.
No request signing and no mTLS on the partner API. The API key is a bearer credential: anyone holding it can use it, from anywhere the allowed-address list permits. A request altered in flight by a proxy you control is not detectable by us. This is a statement about the partner API only — the dashboard does prove possession, described under Dashboard access.
No per-resource scopes.
readandwriteare the whole set. There is no key that may create payouts but not read beneficiaries.
Key lifecycle
No editing a key in place. Scopes, allowed addresses and expiry are fixed at issuance; changing one means revoke and re-issue. Rotation deliberately carries them forward rather than reopening them.
No API-driven issuance. Keys are minted, rotated and revoked by a human in the dashboard. A key cannot mint another key, which also means you cannot automate rotation end to end — the rotate call itself is dashboard-authenticated.
Webhooks
No self-serve signing-secret rotation. The signature format supports an overlap, but there is no endpoint that rotates a live endpoint's secret. To replace one, register a new endpoint and remove the old one.
No published egress addresses. We do not publish the IP ranges our deliveries originate from, so you cannot allowlist us inbound by address. Authenticate deliveries by their signature, which is what it is for.
Organization and audit
No SSO, SAML or SCIM. Dashboard access is per-user passkeys and manual membership; there is no directory integration and no automated deprovisioning.
No audit-log API. The key event trail described above is recorded, but it is not exposed as a partner-facing endpoint — you cannot pull it into your own SIEM today. Ask us and we can retrieve it.
The bearer model is the same shape as Stripe, Increase and Mercury, and it is chosen for the same reason: every HTTP client, SDK and test tool works with it unmodified. If your risk assessment needs one of the above, raise it with us before you integrate rather than after.
Your side of the boundary#
The controls above bound what a leaked key can do. They do not stop it leaking.
Store the key in a secrets vault, or an environment variable if you have no vault. Never in source control, a client-side bundle, a mobile app, a screenshot, or a support ticket.
Server-side only. Use a test key in the documentation console if you want to experiment; never put a test or live key in your own browser bundle.
Use a separate key per service — not because they have different powers, but so revoking one does not take down the others.
- Use a read-only key wherever the job only observes.
Reporting a vulnerability#
Email security@avvio.xyz. Please do not open a public issue or disclose publicly until a fix has shipped. We acknowledge within 2 business days and give an initial severity assessment within 5, and we will credit you when the fix ships unless you would rather we did not.
Out of scope: anything requiring a compromised API key — that is the credential working as designed, not a flaw.
If you are filling in a security questionnaire#
The docs describe these controls in integration language. Questionnaires ask for them in another vocabulary; this is the mapping.
| The questionnaire asks about | We call it | Where |
|---|---|---|
| OAuth 2.0 / OIDC | Not offered — bearer API key | Above |
| Scoped tokens, least privilege | read / write scopes |
Above |
| IP allowlisting / whitelisting | Allowed source addresses, IP pinning | Above |
| API key rotation | Rotation with overlap | Above |
| Credential storage at rest | SHA-256 hash, shown once | Above |
| Encryption at rest | Field-level AES-256-GCM under KMS | Above |
| Encryption in transit | HTTPS, and HTTPS required of your webhook endpoint | Above |
| Webhook authenticity | Standard Webhooks HMAC-SHA256 | Webhooks |
| Your egress addresses, for our allowlist | Not published — verify the signature instead | Above |
| SSO / SAML / SCIM | Not offered — passkeys and manual membership | Above |
| MFA on admin accounts | Passwordless passkeys (WebAuthn) | Above |
| Role-based access control | owner / admin / operator / viewer |
Above |
| Audit logging | Key event trail, recorded but not exposed as an API | Above |
| Rate limiting / abuse controls | Per-credential and per-address limits | Going Live |
| Vulnerability disclosure | security@avvio.xyz | Above |