2026-10).
It has no runtime dependency and uses only fetch and WebCrypto, so it runs
on Node 22 or later and on Workers-like runtimes (Cloudflare Workers, Deno,
Bun). It ships ESM and CommonJS.
Install
Example
An Express server for an app with an embedded page and webhooks. Tokens and the refresh lock live in Redis (ioredis). Secrets come from the environment, never from code.auth.callback_url receives:
Tokens
tassyir.authenticate(idToken)verifies the ID token, then reuses the installation’s stored offline token, or exchanges the ID token for one and stores it. Keep that token: each exchange starts a new refresh family, and Tassyir keeps at most 25 per installation. With{ tokenType: 'online' }it exchanges for a one-hour token that acts for the dashboard user; that token is not stored and has no refresh token.tassyir.redeemInstallCode(code)redeems atsy_ic_…code (5 minutes, one use) and stores the tokens.tassyir.oauthis the raw token endpoint client (exchangeIdToken,redeemInstallCode,refresh), with HTTP Basic client authentication. Every refusal is aTassyirOAuthError:erroris OAuth’s code (invalid_grant, …),codeisOAUTH_INVALID_GRANT, andfixsays what to do.TokenStoreis yours to implement (get,set,deleteby installation id).setmust be durable when it resolves, andgetmust return the latestset.MemoryTokenStoreis for tests.
Refresh locking
A refresh rotates the refresh token. If the old token is presented again more than 60 seconds later, Tassyir revokes every token of the installation, and the merchant has to re-open or reinstall the app.TokenManager
(tassyir.tokens) is built so that this does not happen:
- At most one refresh runs per installation in a process. Concurrent callers wait for it and get its result.
refreshLockserialises refreshes across processes. The token set is read again inside the lock, so a process that waited picks up the fresh token instead of presenting the spent one.- The rotated set is written to the store before the new access token is
used. If that write fails, the call throws
TOKEN_STORE_FAILED. The process keeps the set in memory and writes it again on the next call. - A refresh whose answer was lost is retried with the same token, for at most 40 seconds. That is inside the 60-second grace, so the retry is safe.
- The process never presents a refresh token it has already rotated
(
REFRESH_TOKEN_ALREADY_USED), even if a stale store hands it back. - A refresh token that Tassyir refuses is removed from the store. The next
authenticatethen exchanges again.
refreshLock, two processes can still refresh the same installation
at the same moment. Inside the grace window that is harmless, but use a lock
once you run more than one process.
ID tokens
tassyir.verifyIdToken(token) (or verifyIdToken(token, { clientId, keys }))
checks the token the way the backend does:
alg: EdDSAonly. The key is chosen bykidfromGET /apps/.well-known/jwks.json, which is cached for an hour and fetched again on an unknownkid(at most once every 30 seconds).- The signature is checked before any claim is read.
issmust behttps://api.tassyir.io/appsandaudyour client id.exp,nbfandiatallow the backend’s 30 seconds of leeway, and a token may live at most 60 seconds.
Webhooks
tassyir.verifyWebhook({ headers, body }, { installationId }) (or
verifyWebhook, or verifyWebhookRequest(request, …) for a Fetch Request)
checks a delivery in this order:
- The
v1aEd25519 signature over{webhook-id}.{webhook-timestamp}.{body}, on the raw body bytes. During a key rotation a delivery carries several signatures, and any one that verifies is enough. Keys come fromGET /apps/webhook-keysand are fetched again when no key verifies. - The timestamp, within 5 minutes of now.
- The envelope’s
appId(yours) andinstallationId(your check: an id, a list, or a function). - The unsigned
tassyir-*headers must agree with the signed envelope.
KEYS_UNAVAILABLE: answer 503 and Tassyir retries the delivery later.
Test deliveries from the developer console carry sample data. They are
refused with WEBHOOK_TEST_DELIVERY unless you pass
{ testDeliveries: 'accept' }. The SDK recognises them by their signed
dlv_test_… id and evt_0 envelope id, not only by the tassyir-test
header.
delivery.event is typed per topic: switch (delivery.event.type) narrows
data.
Admin API
tassyir.admin(installationId) returns an AdminClient that uses the
installation’s stored token and refreshes it as needed. To call with a token
you hold (an online token, say), use tassyir.adminWithToken(tokens) or
new AdminClient({ accessToken }). Paths are relative to /apps/2026-10, and
paths, parameters, bodies and answers are typed from the OpenAPI document:
- Errors. A problem+json answer becomes a
TassyirApiErrorwithcode,fix,requestId,status,retryAfterSeconds,rateLimitand the wholeproblem.AdminApiErrorCode<'post', '/orders/{id}/confirm'>lists the codes that call can answer. - Idempotency. Every write sends an
Idempotency-Key: a new UUID per call by default, reused by the SDK’s own retries. You can pass your own key, orfalseto send none. - Rate limits. Every answer’s
RateLimit-*headers reachonResponseandApiResponse.rateLimit. - Retries are off by default; turn them on with
retries: 3. When on, the SDK waitsRetry-Afterand retries, writes included, 429RATE_LIMITED(the installation’s bucket), 429BUSINESS_RATE_LIMITED(the ceiling all the business’s apps share), 429BULK_EXPORT_RATE_LIMITED(5 bulk exports per business an hour) and 503API_BUSY(the API was too busy and did nothing); aRetry-Afterlonger thanmaxDelayMs(30 s by default) throws at once. It retries 409IDEMPOTENCY_KEY_IN_USE, and any other 503, network errors, 502 and 504 for requests that are safe to repeat (reads, and writes with a key). It retries 500 only for reads. A 401APP_TOKEN_INVALIDgets a new token and one more try. It never retries 429IMAGE_QUOTA_REACHED(POST /images): a daily quota is not worth retrying, so it throws at once and the error’sproblemsays which quota is full (per,window:dayorminute,limit,retryAfter), for the app to wait or stop. It also never retries 503BULK_EXPORTS_NOT_CONFIGURED(exports are off on that Tassyir environment) or 409BULK_EXPORT_IN_PROGRESS(wait forbulk/finished, or readGET /bulk/exports/{id}). - Pagination.
admin.paginate(path, { query })walks the keyset cursors and yields items. fieldstakes a list of field names, typed per resource:fields: ['status', 'total'].- Money stays a decimal string (
"2500.00"). JSON is parsed as it is received; use a decimal library for arithmetic, neverparseFloat.
Errors
Everything the SDK throws is aTassyirError. It has a stable code to act
on, a message for people, and a one-sentence fix. The subclasses are
TassyirVerificationError, TassyirOAuthError, TassyirApiError and
TassyirTokenError. No error message contains a token or a secret.
Runtimes
Ed25519 verification uses WebCrypto (crypto.subtle, algorithm Ed25519).
That needs no dependency, and the same code runs on Node 22, Cloudflare
Workers, Deno and Bun, so this package does not use node:crypto or jose.
The test suite checks the built bundle against tokens and deliveries signed
by the Tassyir backend itself. Inject fetch to add a proxy, logging or a
test double.
Tassyir Hosting
Apps hosted on Tassyir Hosting will hold no token: the runtime will call the API for them. That runtime is not built yet. It will plug in through thefetch option and the accessToken provider of AdminClient.
Updating the API types
The OpenAPI document is vendored inopenapi/ at a recorded backend commit.
With a clone of tassyir/backend in TASSYIR_BACKEND_DIR, run
npm run openapi:check -w @tassyir/app to compare it with the backend, and
npm run openapi:update -w @tassyir/app to vendor the new file and
regenerate the types. npm run vectors:generate -w @tassyir/app re-signs the
cross-check fixtures with the backend’s own code.