Skip to content

SDK overview

Every request that reaches your app has been through our sign-in, unless it came in on a public path. The edge adds a signed token saying who the caller is, and your app verifies it to learn their email and role. A webhook delivery on a verified public path gets a different kind of token, which says which provider signed it. See Webhook deliveries.

The SDKs verify the token in one call. To deploy, share or read the audit log from code instead, see API clients. They are open source (Apache-2.0) at github.com/Forculus-Labs/forculus.

Terminal window
npm install @forculus/sdk

Workers, Node 20 or newer, Deno and Bun.

import { requireUser, Unauthorized } from "@forculus/sdk";
export default {
async fetch(request, env) {
try {
const user = await requireUser(request, env);
return new Response(`hello ${user.email}`);
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
},
};

Deploy it and call it as yourself:

Terminal window
forculus curl acme/hello
hello someone@example.com
200 OK

Each language page covers the framework integrations: Node.js, Python, Go and Rust.

A verified caller has six fields. Each SDK spells them its own way.

Node.js Python Go Rust
Person or machine kind kind Kind kind
Stable id sub sub Sub sub
Email email email Email email
Role on this app role role Role role
Workspace id workspaceId workspace_id WorkspaceID workspace_id
Agent’s name agentName agent_name AgentName agent_name
Is it an agent? isAgent(user) user.is_agent user.IsAgent() user.is_agent()
  • kind is user for a person and service for a bypass token, one of the workspace’s agents or one of the app’s scheduled jobs. These calls refuse a webhook delivery’s token. See Webhook deliveries.
  • sub is the person’s Forculus id, like usr_…, service:<token id> for a token, agent:<agent id> for an agent, or scheduler:<schedule id> for a scheduled job. Use it as the key when you store something per caller. Before doing a scheduled job’s work, check kind is service and sub starts with scheduler:.
  • email is empty for a token or an agent: null in Node.js, None in Python and Rust, "" in Go.
  • The agent’s name is there only when one of the workspace’s agents called, and empty for everyone else, the same way email is. Show it, so people can see which agent did something, but decide what an agent may do by sub: whoever manages an agent can rename it, and once it ends its name is free for a new one. The agent’s name, and whether it’s an agent, are new in version 0.2.0 of each SDK.
  • role is viewer, editor or owner.

A script calling with a bypass token has no email, so code that prints user.email prints null:

Terminal window
FORCULUS_TOKEN=fcb_... forculus curl acme/hello
hello null
200 OK

Fall back to the id when there is no email: user.email ?? user.sub.

Roles are ordered viewer, editor, owner. An owner can do anything an editor can.

Every SDK has a check that returns true or false, and one that fails with a 403 error:

Ask Refuse
Node.js hasRole(user, "editor") requireRole(user, "editor") throws Forbidden
Python has_role(user, "editor") require_role(user, "editor") raises Forbidden
Go forculus.HasRole(user, forculus.RoleEditor) forculus.RequireRole(user, forculus.RoleEditor) returns an error matching forculus.ErrForbidden
Rust user.has_role(Role::Editor) user.require_role(Role::Editor) returns Err(Forbidden)

Answer 401 when you don’t know who the caller is, and 403 when you know and they may not do this.

A role the SDK doesn’t know grants nothing. If we add a role, an app built with an older SDK still lets callers with it through requireUser, and every role check refuses them, so a route that checks a role is closed to them and one that doesn’t is open. Asking for a role that doesn’t exist refuses everyone.

The edge sends the token in the X-Forculus-Identity header. It is a JWT signed with RS256 that lasts 60 seconds, and the edge makes a new one for every request. A request with two of the header is refused by every SDK.

Decoded, a token looks like this:

{
"alg": "RS256",
"kid": "…",
"typ": "JWT"
}
{
"wid": "2a64ea1d-cebb-4356-89a1-52d01912cf20",
"role": "owner",
"kind": "user",
"email": "someone@example.com",
"iss": "https://edge.forculuslabs.tech",
"aud": "adc2f06a-aec5-45e0-bea7-746ac5390091",
"sub": "usr_8xeuqtea67e3kkqpa0od9qhfvl",
"iat": 1790196735,
"exp": 1790196795,
"jti": "97369b6b-123d-4a16-acfe-9c07460bb259"
}
Claim What it is
iss The edge, https://edge.forculuslabs.tech
aud Your app’s id. A token made out to another app fails here.
sub The caller’s stable id
kind user or service
email The person’s email. Absent for a token.
role Their role on this app
wid The app’s workspace id
iat, exp When it was made, and 60 seconds later
jti A random id for this token

The SDKs check, and refuse the request if any check fails:

  • the algorithm is RS256, whatever the token’s own header says
  • the token names a key (kid), and that key signed it
  • iss is FORCULUS_IDENTITY_ISSUER
  • aud is FORCULUS_APP_ID, as one string and not a list
  • exp hasn’t passed, with no clock leeway, and nbf has passed if the token has one
  • iat, sub, kind, role and wid are there, and email too for a person
  • kind is user or service. A scheduled job’s is service, with a sub that starts with scheduler:
  • no claim repeats one of those names in another case, like EXP

The keys come from FORCULUS_JWKS_URL. The SDKs cache them for five minutes. A token signed by a key they haven’t seen makes them fetch again, at most once every ten seconds. A fetch that takes more than three seconds fails, redirects aren’t followed, and RSA keys shorter than 2048 bits are ignored.

A delivery on a verified public path carries a token of kind webhook. We check the provider’s signature before sending the delivery on, so your app checks our token and doesn’t need the provider’s secret. The token is made out to webhook:<app id>, not your app’s id. It names the provider and the public path’s pattern, and has no email and no role. requireUser refuses it, and requireWebhook refuses everything else, a person’s token included.

import { requireWebhook, Unauthorized } from "@forculus/sdk";
export default {
async fetch(request, env) {
try {
const hook = await requireWebhook(request, env, { provider: "stripe" });
const event = await request.json();
// hook.delivery is Stripe's event id. Skip one you've handled.
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
},
};

Each call names the providers the route takes, one or a list. A delivery from any other provider is refused, and so is every delivery when none is named. Each language’s page shows where to put the webhook route in its frameworks, so a check for people doesn’t refuse the delivery first.

A verified delivery has five fields:

Node.js Python Go Rust
Provider provider provider Provider provider
Public path pattern pattern pattern Pattern pattern
Provider’s signed id delivery delivery Delivery delivery
When it was signed signedAt signed_at SignedAt signed_at
Workspace id workspaceId workspace_id WorkspaceID workspace_id
  • provider is stripe, github, slack, shopify, standard_webhooks or hmac_sha256.
  • pattern is the public path it came in on, such as /hooks/stripe or /hooks/*.
  • delivery is the provider’s id where its signature covers one: Stripe’s event id, Slack’s event_id, the Standard Webhooks message id. GitHub, Shopify and HMAC-SHA256 sign none, and it’s empty: null in Node.js, None in Python and Rust, "" in Go.
  • signedAt is when the provider signed it, for Stripe, Slack and Standard Webhooks: unix seconds, or a time.Time in Go. For the others it’s empty, like delivery, and a zero time.Time in Go.

For a webhook’s token the SDKs check the signature, iss and the expiry as they do for a person’s, and then:

  • aud is webhook: followed by FORCULUS_APP_ID, as one string
  • kind is webhook, and sub and wid are there
  • provider is one the SDK knows and the route names
  • pattern isn’t empty
  • delivery, if present, is a string that isn’t empty, and signed_at, if present, is a whole number of seconds

Webhooks and public paths lists the token’s claims.

We give every app three variables:

Variable Value
FORCULUS_APP_ID Your app’s id, the only audience the SDK accepts
FORCULUS_IDENTITY_ISSUER https://edge.forculuslabs.tech
FORCULUS_JWKS_URL https://edge.forculuslabs.tech/.well-known/jwks.json

A Worker gets them as bindings, the env your fetch handler receives. A container gets them as environment variables. You can’t set or change them, because forculus env set refuses any name starting with FORCULUS_.

The issuer and key set are always https. From version 0.3.0 the SDKs refuse an http one unless FORCULUS_DEV is 1, which only forculus dev sets, on your machine. We never set it, and a container starts with it empty.

The key set is the one address of ours your app’s outbound requests can reach, so the fetch isn’t blocked.

The edge also sends X-Forculus-Email, the caller’s email in plain text, for display in a log line or a greeting. Your app can’t tell from the header whether the edge set it: a request that reached your app some other way, or on a public path, can carry any header. See How access works.

The SDKs read only the signed token. A request with X-Forculus-Email and no valid token gets 401, the same as a request with no headers.

If your language has no SDK, verify the token yourself with a JWT library:

  1. Read X-Forculus-Identity. Refuse the request if it’s missing.
  2. Pin the algorithm to RS256. Don’t let the token’s header choose it.
  3. Fetch the key set from FORCULUS_JWKS_URL, and pick the key by the token’s kid.
  4. Check the signature, iss against FORCULUS_IDENTITY_ISSUER, aud against FORCULUS_APP_ID, and exp.
  5. Refuse on any failure, including a key set you couldn’t fetch.

On a route for a verified webhook, check the same way with one change: aud is webhook: followed by FORCULUS_APP_ID. Then check that kind is webhook and provider is the one the route takes. The audience is what keeps a webhook’s token from being accepted as a person’s, and a person’s as a webhook’s, so compare it exactly.

Don’t authorize on a header alone.

The SDKs have no setting that skips the signature check. To run your app locally with real tokens, as an owner, a viewer, an agent, a webhook or someone the app isn’t shared with, use forculus dev. It sets FORCULUS_DEV=1, which lets the SDKs take its http issuer on your machine and changes nothing else.