The Token Is the Tenant
An inbound webhook endpoint has no login, no session and no workspace in its address. One header decides which of thousands of workspaces an event belongs to, and the way that decision is made is the whole security model.
Every system that accepts events from other systems has the same shape at its edge: a public address, no sign-in, and a payload that arrives unannounced. The interesting question is not how it is protected — everyone says a token — but what that token is asked to do. Here it is asked to do two things at once, and the second one is the one worth reading closely: it does not only prove the caller is allowed in, it is the only thing that says which workspace they are talking about.
The address does not name you
The obvious way to build this is to put the workspace in the URL. One address per customer, easy to read in a log, easy to hand out, and it makes the routing trivial.
It also makes the workspace identifier a public string that appears in configuration screens, support tickets and browser histories, and it splits the question of who is calling from the question of whose data this is. Two answers, arriving from two places, that have to agree.
This endpoint takes one address for everybody. The workspace is not in the path, not in the body, and not asked for anywhere: it arrives attached to the credential the token resolves to. The caller never states which workspace they mean, so they can never state it wrongly.
What is stored is not the token
A token is issued as a long random string with a short readable prefix. What gets written to the database is a one-way digest of it plus those first few characters.
So the arriving token is hashed and the hash is looked up. The comparison happens on values that are useless to anybody who reads them, and there is no query anywhere that can return the token itself — not to an operator, not to support, not to us.
The prefix exists because a screen listing four keys needs to be able to tell you which one is which. It is long enough to recognise and short enough to be worthless.
A key you can be shown again is a key somebody can be persuaded to show you.
This has a consequence people meet on their second day and it is deliberate: a token shown once and then lost cannot be recovered, only replaced. The settings screen offers rotation rather than retrieval, and rotation is the honest operation — it is what you would want to have happened if the token had leaked, which is the case you cannot rule out.
One table, four kinds of key
The same table holds the credentials for four different things: this endpoint, the read-only developer API, a public stock lookup, and an internal health feed.
That is convenient and it introduces a specific way to be wrong. A valid token for one of those is a real row with a real workspace on it, so a lookup that matches only on the hash would happily accept a read-only API key as an inbound webhook credential.
The lookup therefore matches on the service as well as the hash. A token is valid for the thing it was issued for and for nothing else, which is a sentence worth being able to say plainly rather than assuming.
On top of that each credential carries a list of scopes, and the check asks for the one this endpoint needs. Keys issued through the settings screen always carry it. The scope narrows an identity that has already been proved rather than establishing one, which is why an unscoped key is treated as unrestricted rather than as unusable — a distinction that only matters for keys created outside the screen.
Three ways a key stops working
Usability is a separate question from existence, and it is asked every single request rather than at issue time.
-
Switched off
An active flag, which is what revoking and rotating both set. The row stays, so its history stays with it.
-
Revoked
A timestamp recording when. Checked independently of the flag, so a row that somehow keeps the flag still fails.
-
Expired
An optional end date. Absent means no expiry; present means the clock is checked against now on every call, not on a schedule.
Each of those is checked on its own rather than folded into the one flag, which costs nothing and means that any single one of them holding is enough to refuse. The two clocks — revoked and expired — are read at request time, so there is no window in which a key that should have stopped working keeps working until something catches up with it.
And a second lock, which is optional and always issued
A token in a header proves the caller has the token. It does not prove that the message was not altered on the way, and it travels in full on every request.
So each credential also gets a signing secret, and where one exists the request must carry a digest of its exact bytes computed with that secret. Three properties of that check are worth naming, because each of them looks like something a tidier would remove: it is computed over the raw body rather than a re-encoded copy, it is compared in constant time, and a missing signature is a refusal rather than a reason to skip the check.
The code path allows a credential without a secret. The screen that creates them always issues one. That is the right way round: the capability is general and the default is strict.
The secret, unlike the token, can be shown again — and asking for it re-checks the operator's own password first, then stashes the value under a single-use reference that expires in minutes. It is read rather than consumed on collection, deliberately, so that a page prefetch cannot burn the reveal before a person sees it.
Three endpoints, three answers
This product has three public webhook endpoints and each of them identifies its workspace differently. That looks like inconsistency and is worth laying out, because the difference is forced by what the caller can be asked to do.
| Inbound events | Workflow triggers | Document signatures | |
|---|---|---|---|
| Where the workspace comes from | The credential the token resolves to | The address that was called | The document id in the body |
| What the caller states | Nothing | The workspace, in the path | A document reference |
| What authenticates it | A hashed token, plus a signature | The path and the event name | A digest keyed to that workspace |
| Why it is shaped that way | We issue the credential, so we can put anything on it | The caller is a system we do not configure | The sender knows the document, not the workspace |
The rule underneath all three is the same: whoever can be made to hold a secret should hold one, and whoever cannot must be identified by something we can verify independently. Where we issue the credential, that is a token we generated. Where we do not, it is a value in the payload that we can check against a key of our own.
What is in place
How an unannounced request becomes a workspace
One address for every workspace
The endpoint takes no workspace in its path, so there is no public identifier to leak and nothing for a caller to get wrong.
A hashed lookup, not a stored token
The arriving token is digested and the digest is matched. The plaintext is written nowhere, so no query and no operator can produce it.
A readable prefix for the settings screen
The first ten characters are kept so a list of keys can be told apart, which is short enough to be useless on its own.
The service checked with the hash
Four kinds of key share one table, so the lookup matches the service too and a key issued for one endpoint cannot be spent at another.
A scope asked for by name
Each credential carries what it is allowed to do and this endpoint asks for its own scope on every call.
Three independent liveness checks
Active, not revoked and not expired are asked separately at request time, so any one of them holding is enough to refuse.
A signing secret on every issued key
The digest is over the raw bytes, compared in constant time, and a missing signature is a refusal rather than a skipped check.
The secret revealable behind a password
Showing it again re-checks the operator's own password and hands back a reference that expires in minutes.
Rotation as a new row
A rotated key revokes the old row and creates a new one linked to it, carrying the scopes and the remaining lifetime, so the audit trail survives the rotation.
Ownership re-checked on every management action
Rotating, revealing or deleting a key matches both the workspace and the service before it does anything, and answers a mismatch with a not-found.
A per-minute ceiling on the endpoint
The public address is rate limited independently of anything the credential says, so an unauthenticated flood is bounded before the lookup runs.
Last-used recorded quietly
Each accepted call stamps the credential without touching its updated timestamp, so a key that has gone silent can be spotted.
Every item here is answering the same question in a different place: which workspace is this, and is this caller still allowed to speak for it. The parts that read as bureaucracy — a service filter, three separate liveness checks — are each the one that holds when another has been changed by hand.
Three positions held on purpose
- A token is shown once. There is no retrieval path for anybody, including us, which means a mislaid key is replaced rather than recovered — and replacement is the operation you would want anyway, because a key you have lost track of is a key you cannot say is private.
- The workspace is derived, never declared. A caller who cannot name a workspace cannot name the wrong one, which removes a whole category of cross-workspace mistake rather than defending against it.
- A shared table needs the service in the lookup. Four kinds of credential live together for good reasons, and the moment they do, matching on the secret alone quietly makes every key interchangeable.
Five questions for anybody offering you an inbound webhook
Is my workspace identifier in the URL?
A good answer sounds like
No.
What ours actually is
One address for everybody. The workspace comes from the credential the token resolves to.
Can you show me my token again?
A good answer sounds like
No.
What ours actually is
Only a one-way digest and a ten-character prefix are stored. Rotation replaces it.
Is the payload signed as well as authenticated?
A good answer sounds like
Yes, over the raw bytes.
What ours actually is
Every key issued through the screen carries a signing secret, checked in constant time.
What happens when I revoke a key mid-stream?
A good answer sounds like
The next request fails.
What ours actually is
Active, revoked and expired are all read at request time rather than cached.
Could a key for another integration work here?
A good answer sounds like
No.
What ours actually is
The lookup matches the service alongside the digest, and the four services share one table.
Our take
The security of an inbound webhook is usually described in terms of what it has — a token, maybe a signature — and that description is not much use, because everything has those. What tells you how it was built is where the workspace comes from. If the caller states it, then the token and the claim have to agree, and something somewhere has to check that they do; that check is easy to write, easy to write slightly wrong, and impossible to notice being wrong from any screen, because genuine traffic is correct by construction. Deriving the workspace from the credential removes the second source rather than reconciling it. It costs a slightly less readable log line and it makes a class of failure unavailable, which is the trade worth taking every time.
Ask where the workspace comes from
It is a one-sentence question with a revealing answer, and it distinguishes an endpoint that was designed from one that was added.
Talk through inbound eventsFrequently asked questions
What do I have to configure to send events in?
One address, one token header and, where a signing secret was issued with the key, one signature header. The address is the same for every workspace, so nothing about your organization appears in the URL.
I have lost the token. Can you send it to me again?
The token is stored as a one-way digest and a ten-character prefix, so there is no path that produces the original — for support, for an administrator, or for us. The settings screen rotates the key instead, which revokes the old one and issues a new one carrying the same scopes and remaining lifetime.
Does the signing secret work the same way?
The secret can be shown again, because verifying a signature needs both sides to hold the same value. Asking for it re-checks your own password first and hands back a reference that expires after a few minutes.
Could a token from one workspace post events into another?
The workspace is read off the credential the token resolves to, and no part of the request states one. There is no value a caller can supply that changes which workspace an event lands in.
What happens if somebody keeps posting to the endpoint with a bad token?
The address carries a per-minute limit that applies before any lookup happens, and a request with no matching credential is answered with an unauthorized response that says nothing about whether the token exists.