Security
Keys, scopes, IP allow-lists, and what the API does when one of them leaks.
Most payment breaches are not clever. A secret key ends up in a repository, in a mobile bundle, or in a screenshot pasted into a chat, and whoever finds it can do everything you can do. The controls on this page exist to make that mistake small: harder to make, narrower when made, and quick to undo.
Three key types, and only one is safe in public
A key is a prefix, a dot, and a random secret. The prefix is the whole security model.
| Prefix | Lives in | May do |
|---|---|---|
pk. / pk_test. | Browsers and mobile apps | Create payments. See the endpoints that accept it |
sk. / sk_test. | Your server, only | Everything your team can do |
rk. / rk_test. | Your server, scoped | Only the resources you ticked |
Wajub never stores a key in plain text. A key you misplaced can be shown again in the Dashboard by a member of your team, after they confirm their password. A key you believe was exposed must be rolled.
The gates a request passes before it reaches an endpoint
Each gate answers with its own status code, and the codes are easy to misattribute.
| Gate | Refuses with | When |
|---|---|---|
| API version | 400 | X-Wajub-Version names a version the API does not serve |
| Credential check | 401 | Unknown, revoked, inactive, or past expires_at |
| Browser detection | 403 | An sk. key is used from a browser |
| Account restriction | 403 | Wajub has closed the API for the account (error_code: merchant_restricted) |
| IP allow-list | 403 | The caller's IP is not on the key's list |
| Key class | 406 or 403 | A public key on a private route, or a scope the key lacks |
| Connection | 403 | An X-Sync connection that is not yours, not active, lacks the capability, or has its payments suspended |
| Environment and plan | 403 | A live-only route called with a sandbox key, or Sync or Tax not in your plan (error_code: feature_not_in_plan) |
| Rate limits | 429 | The team, the key or the endpoint is over its ceiling, see Rate limits |
The version and the rate limits are checked before the key, so a call with a wrong key can still
answer 400 or 429.
A private key used from a browser is reported, not just refused
Never use an sk. key from a browser. When Wajub detects one, it rejects the request with 403
and emails an alert to the key's owner. You will hear about the leak, which is the point, but by
then the key is in a bundle someone downloaded. Roll it.
Narrow the key before you need to
Two controls apply to every key type, including public ones, and almost nobody turns them on.
An IP allow-list pins a key to the machines that should be using it. Entries are exact IPv4 or
IPv6 addresses, or CIDR ranges in either family, and an empty list means no restriction. A call from
anywhere else gets 403 IP address not allowed for this API key, whatever the key's type. If your
backend leaves a fixed egress address, this turns a leaked key into a useless string.
An expiry puts a date on the key. Past it, every call answers 401. Set one on anything
temporary: a contractor's key, a migration script, a demo.
What a restricted key can be scoped to
Scopes are {resource}.{read|write}. The verb decides which half applies: GET, HEAD and
OPTIONS need read, everything else needs write.
| Resource | Scopes |
|---|---|
payment | payment.read, payment.write |
customer | customer.read, customer.write |
transfer | transfer.read, transfer.write |
refund | refund.read, refund.write |
recipient | recipient.read, recipient.write |
invoice | invoice.read, invoice.write |
dispute | dispute.read, dispute.write |
webhook | webhook.read, webhook.write |
event | event.read, event.write |
link | link.read, link.write |
identity | identity.read, identity.write |
tax | tax.read, tax.write |
balance | balance.read |
account | account.read |
A restricted key is refused on any route that maps to no resource at all, with
403 This API key cannot access this resource. That is deliberate, and it means a restricted key is
an allow-list rather than a filter: if a route is not in the table above, the key cannot reach it.
Four paths are the exception and stay open to any valid key, because they carry nothing sensitive:
/channels, /countries, /currencies, and the API root. /shield has no scope at all: no
restricted key can reach it, only a private key.
Note the two read-only rows. balance and account have no write scope, so a restricted key can
read a Sync connection but never create one. Use a private key for that, on your server.
Rolling a key is instant, in both directions
Rolling replaces the value in place, and the old value stops working from the next request. There is no grace period and no overlap. Rolling the key your production checkout is using takes your checkout down until the new value is deployed.
The safe sequence has no window at all.
- 1
Create a second key of the same type
Your team can hold several of each type, publishable keys included: Add new key on the keys page. The new one works from the moment it exists.
- 2
Deploy it
Ship the new value to every service that calls Wajub, and wait until none of them are still running the old one.
- 3
Check the old key is idle
Its
last_used_atstops moving once nothing is using it. - 4
Delete the old key
Now it is dead, and nothing noticed.
Roll in place only when the key is already compromised. There, the outage is the cheaper of the two costs.
When a key has leaked
- 1
Revoke it
Delete or deactivate it in the Dashboard. Every call using it answers
401from that moment. - 2
Put a working key back
Create a replacement and deploy it, following the sequence above for whatever still runs.
- 3
Read what was done with it
Konsole has the request log. Look for calls from IPs you do not recognise, and for refunds, transfers and beneficiaries in particular.
- 4
Close the hole
A key in a git history is still in the git history after the file is edited. Rotate first, then clean.
Webhook secrets are credentials too
Every delivery is signed with a secret that belongs to the endpoint, not to your API key. Verify
before you parse, and rotate the secret the same way you rotate a key, with
POST /webhooks/{id}/rotate-secret.
Two failures are worth naming because both look like working code. Parsing the body before checking
the signature means anyone who knows your URL can invoke your fulfilment. And comparing signatures
with == leaks timing: use your language's constant-time comparison, after checking that the two
strings are the same length, since several implementations throw rather than return false on a
length mismatch.
Signature verification has the manual implementation for stacks without an SDK.
Sandbox and live share no data
The key prefix decides the environment. A pk_test. or sk_test. key sees sandbox data only and
never a live object, and the reverse holds too. Sandbox object ids carry test_ so a value that
crossed environments is visible on sight.
This is also why an id from one environment answers 404 in the other, rather than a permission
error. There is nothing there to permit.
On your side of the line
- Keys go in environment variables, never in source, and never in a variable your bundler inlines.
NEXT_PUBLIC_,VITE_,EXPO_PUBLIC_and their equivalents are a promise to publish. Only apk.key may carry one.- Take the amount from your own database when you create a payment. A price that came from the browser is a price the browser chose.
- Give each service its own restricted key, scoped to what that service does.
- Turn on required two-factor authentication for your team in the Dashboard, and give teammates the narrowest role that lets them work.
- Keep an expiry on anything temporary.
- Declare the sites that embed your checkout in Settings → Checkout embedding before you go live. The page is on every plan. Once the list is set, only those sites can show your checkout. See where the checkout may be embedded.
Reporting a vulnerability
Send it to [email protected] rather than opening a public issue. Include what you found, how to reproduce it, and how to reach you.