Server apps
Sarah's Sunday email job, and the cleaner's phone app. Two credentials, and the difference is whose authority the request carries.
Two things run outside a browser in Sarah's setup, and they sign in differently.
The Sunday email job runs at 6am and sends everyone their week's visits. Nobody's signed in; it acts as Brightside Cleaning itself. That's a secret key.
The cleaners' phone app shows Marcus his own rounds. It acts as Marcus, and shouldn't show him Salem's work. That's a personal token.
| Acts as | Reach for it when | |
|---|---|---|
Secret key sk_… | Your software | Background jobs, syncs, integrations, webhook handlers |
| Personal token | One person | A mobile app or CLI where the audit trail should name the human |
Undecided? You want a secret key. Personal tokens exist for clients that hold someone's password but can't use cookies.
Secret keys
Two environment variables and you're going:
HYPHABASE_SECRET_KEY=sk_live_xxx
HYPHABASE_LOCATION_ID=019aff48-a982-70b4-9be8-57eb265122d7Write the wrapper once, so headers, idempotency and error handling aren't re-derived at every call site:
const BASE = 'https://api.hyphabase.com';
export async function api(path, { idempotencyKey, ...init } = {}) {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.HYPHABASE_SECRET_KEY}`,
'X-Location-Id': process.env.HYPHABASE_LOCATION_ID,
'Content-Type': 'application/json',
Accept: 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...init.headers,
},
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw Object.assign(new Error(body.message ?? response.statusText), {
status: response.status,
errors: body.errors,
});
}
return response.status === 204 ? null : response.json();
}In PHP:
$response = Http::withToken(config('hyphabase.secret_key'))
->withHeaders(['X-Location-Id' => config('hyphabase.location_id')])
->acceptJson()
->get('https://api.hyphabase.com/api/modules/clients');Serving several sites from one job
The site id is per request, not per key. Sarah's Sunday job uses one key and loops:
export const forSite = (siteId) => (path, init = {}) =>
api(path, { ...init, headers: { 'X-Location-Id': siteId, ...init.headers } });
for (const site of await ourSites()) {
const send = forSite(site.id);
const { data: customers } = await send('/api/modules/clients');
await emailCustomerReport(site, customers);
}A key that hasn't been granted a site returns 403 — see
when a request is refused.
Granting the whole group rather than each site means new sites are picked up
automatically.
Tokens for a specific person
Swap someone's email and password for a token:
curl -X POST https://api.hyphabase.com/api/auth/login \
-H "Content-Type: application/json" \
-H "X-Location-Id: $HYPHABASE_LOCATION_ID" \
-d '{"email":"marcus@brightsideclean.example","password":"…","device_name":"Marcus iPhone"}'| Field | Notes |
|---|---|
email | Unique within a site, not globally — the same address can exist at two of your sites. |
password | |
device_name | Optional but worth sending. Labels the token so one phone can be revoked without signing out the others. |
You get back token, the person, and their permissions. Then:
Authorization: Bearer 12|kGz9...
X-Location-Id: 019aff48-a982-70b4-9be8-57eb265122d7| For | Endpoint |
|---|---|
| Get a token | POST /api/auth/login |
| Who is this | GET /api/auth/me |
| Revoke this one | POST /api/auth/logout |
Tokens last 120 minutes and there's no refresh token
Treat a 401 as "ask them to sign in again", and store the token somewhere it
disappearing is survivable. Signing out deletes only the token that made the call, so
Marcus's tablet stays signed in when he signs out on his phone.
Sign-in is limited to 5 attempts a minute per email and address, and 20 a minute per address. Never retry a failed sign-in in a loop — you'll lock out the real person.
Handling webhooks
The natural pairing: something happens, we tell your server, you use your secret key to do whatever comes next.
app.post('/webhooks/hyphabase', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(process.env.WEBHOOK_SECRET, req.body, req.header('HyphaBase-Signature'))) {
return res.sendStatus(400);
}
res.sendStatus(200); // acknowledge fast
enqueue(JSON.parse(req.body.toString())); // then do the work
});Full treatment in Getting told when things happen.