Authentication
Bearer API keys — minting, rotating, revoking. The keys are workspace-scoped — no tenant header needed.
Every request to https://api.wesign.now/v1/* (or its permanent alias
https://api.letssign.now/v1/*) carries a Bearer token in the
Authorization header.
Authorization: Bearer wsk_live_a1b2c3d4e5f6...Keys are workspace-scoped — the bearer token alone tells the API which workspace, brand, hosting region, and reply-to address to use. There's no tenant header, no slug param, no signature on the request. Anything that has the key can act as the workspace.
Key format
| Prefix | Meaning |
|---|---|
wsk_live_ + 32 hex chars | Every key minted since the September 2026 rename. |
lsk_live_ + 32 hex chars | Keys minted before it. Accepted forever — same lookup, same workspace, nothing to rotate. |
We store only the SHA-256 of the full plaintext, plus its first 12
characters (wsk_live_a1b…) as a non-secret label. A bearer that starts
with neither prefix is refused before we touch the database.
Not sure which workspace a key belongs to? GET /v1/me answers with
the workspace id, name, slug and tier. Connectors use it as their
"test authentication" step; it is exempt from the rate limit.
Minting a key
In your workspace dashboard, open Developers → API keys and click Create API key.
- We show the
wsk_live_…plaintext once, in a copy-and-confirm modal. Store it in your secret manager (1Password, Doppler, AWS Secrets Manager, GitHub Actions secrets) immediately — there's no recovery if you lose it. - The dashboard keeps the first 12 characters as a non-secret
identifier (
wsk_live_a1b…) so you can label rows in the audit log later.
Rotating a key
Rotation is "two keys live for a moment" rather than "regenerate":
- Create a new key. Both old and new are now active. Update one deployment to use the new key.
- Revoke the old key. From Developers → API keys,
hit the Revoke button. Revocation is immediate — the next
request with the old key returns
401 invalid_key.
This pattern means you can roll a key without a sub-second outage
window. We don't support "scheduled rotation" or auto-expiry yet —
ask if you need it. You do not need to rotate an lsk_live_ key
to a wsk_live_ one; both stay valid.
Revoking
Same place: hit Revoke on a row. Effective immediately. The audit trail records who revoked it and when.
Rate limit is per key
Every key has its own bucket of 60 requests per 60-second window,
tracked on the key's own row. Two keys in the same workspace are two
buckets — a 429 on one says nothing about the other. Details and the
recommended back-off in Rate limits.
Restricting a key to your own IP addresses
By default a key works from anywhere: whoever holds it can act as your workspace from any machine on the internet. If your integration calls us from a fixed address — an office NAT, a static egress IP on your VPC, a bastion host — you can pin the key to it, and calls from anywhere else are refused before the key is allowed to do anything.
An empty list means unrestricted. This is the opposite of the embed-origin list, and it is the half people get wrong: a key with no entries is not locked out — it is open to every address. That is the state every key is in until someone deliberately adds an entry. Only a non-empty list restricts anything.
Manage it in Settings → API, on the key's own card, under Allowed IP addresses. Each key has its own list; two keys in the same workspace restrict independently.
What goes in the list
| Entry | Matches |
|---|---|
203.0.113.7 | that one IPv4 address |
203.0.113.0/24 | every address in that IPv4 block |
2001:db8::1 | that one IPv6 address |
2001:db8::/32 | every address in that IPv6 block |
- Up to 50 entries per key. Mix IPv4 and IPv6 freely — and do list both if your backend can egress over either, or the day it picks IPv6 will look to us exactly like an attack.
- We canonicalise on save, so
203.0.113.7/32is stored as203.0.113.7,203.0.113.7/24as203.0.113.0/24(host bits are zeroed), and2001:0DB8:0000::/32as2001:db8::/32. What the card shows is what we match against — the UI, the database and the check can never disagree about spelling. - An IPv4-mapped IPv6 caller (
::ffff:203.0.113.7) matches the plain203.0.113.7entry, and matches203.0.113.0/24. - Anything we can't parse is refused at the form rather than stored. Notably
::ffff:203.0.113.0/24is rejected: read literally that is a quarter of the IPv6 address space, which is never what the typist meant.
Which address we compare
The address is the public IP our platform observed the connection coming
from — your backend's egress address. Not a header you set, not a field in
the body. In particular we ignore X-Forwarded-For: any caller can put any
value in that header, so an allowlist that trusted it would be decoration.
See Security → Origin is not an access control for the REST
API.
If the address can't be determined at all, a restricted key is refused rather than waved through. A key with an empty list is unaffected either way.
The refusal
A key refused by its allowlist answers 403, not the generic
401 invalid_key — your key is fine, your address isn't, and the two send
you down completely different roads:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "This API key does not allow calls from 203.0.113.9. Add that address to the key's IP allowlist in Settings → API, or empty the list to allow any address.",
"code": "ip_not_allowed",
"ip": "203.0.113.9"
}ip is the address we actually saw. Paste it into the allowlist and the next
call goes through — no guessing what your egress address is.
The key's card also shows the address your browser is on, with an Add
this IP button next to it. Reach for that one only when your calls really do
come from where you are sitting: a server almost always egresses from a
different address than your laptop, and the address in the 403 body is the
one that matters. (The button appears only when we can actually verify your
browser's address — an unverified address would be the exact typo that locks
you out.)
When the caller isn't reaching us through our edge at all, the address is
unknown and the same code comes back with "ip": null:
{
"error": "This API key restricts which IP addresses may use it, and the caller's address could not be determined, so the call was refused. Empty the key's IP allowlist in Settings → API to allow any address.",
"code": "ip_not_allowed",
"ip": null
}It gates API keys only
Worth spelling out, because this is where a control like this usually bites:
- Your workspace UI is never gated by it. You can always sign in from any network, open Settings → API and empty the list. A mistyped address locks out your integration — never you.
- Signing pages are never gated by it. Your recipients can be anywhere, on any network. Nothing you put in this list can dead-end a signer.
- It applies to
/v1/*calls made with that one key. Other keys, the dashboard, webhooks we send you, and embedded signing sessions your signers open are all untouched.
What it is and isn't worth
- It limits where a stolen key can be used from. An attacker who lifts your key out of a CI log still has to call from an address you listed.
- It does not make a leaked key safe. Anything running on an allowed address — including other code behind the same NAT, and whoever got into it — can still use the key. Rotate a leaked key; the allowlist is not a substitute.
- It is useless if your egress is dynamic. Serverless without a NAT gateway, most PaaS dynos, a laptop on home broadband: those addresses change without warning and you will lock your own integration out. Give the caller a static egress address first, or leave the list empty.
- It says nothing about who inside your company holds the key. One key per integration, plus revocation, still does that work.
Error shape
Bad, missing, or revoked keys all return:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "Unauthorized",
"code": "invalid_key"
}A key that is valid but refused by its
IP allowlist returns 403
with code: "ip_not_allowed" and the observed address instead. Every other
code is in Errors.
The code field is stable — pin your error handling to that, not the
human-readable error message which we may improve over time.
Security checklist
Never commit keys. Ship them via your secrets manager, environment variable, or platform vault. Treat a leaked key the same as a leaked password — revoke immediately and audit recent activity.
- All API calls go over TLS 1.2+ on
api.wesign.nowand on theapi.letssign.nowalias. Pin certs if you're extra-paranoid; we use Let's Encrypt with normal renewal. - Keys are bearer tokens — there's no extra signing scheme on the request body. If you want HMAC-signed traffic, register a webhook — every delivery we send you is HMAC-SHA256 signed.
- If your integration calls from a fixed egress address, pin the key to it with an IP allowlist. It narrows where a stolen key can be used from; it does not replace rotating one that leaked.
- Audit logs surface in the dashboard for every key, with last-used timestamps and per-request IPs (masked).
