Skip to main content
The server side of a Tassyir app. It gets and refreshes an installation’s tokens, verifies the ID tokens the dashboard hands your embedded page and the webhooks Tassyir sends, and calls the Admin API through a client typed from its OpenAPI document (version 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.
A headless app (no embedded page) gets its first tokens from the install code that its 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 a tsy_ic_… code (5 minutes, one use) and stores the tokens.
  • tassyir.oauth is the raw token endpoint client (exchangeIdToken, redeemInstallCode, refresh), with HTTP Basic client authentication. Every refusal is a TassyirOAuthError: error is OAuth’s code (invalid_grant, …), code is OAUTH_INVALID_GRANT, and fix says what to do.
  • TokenStore is yours to implement (get, set, delete by installation id). set must be durable when it resolves, and get must return the latest set. MemoryTokenStore is 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.
  • refreshLock serialises 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 authenticate then exchanges again.
Without 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: EdDSA only. The key is chosen by kid from GET /apps/.well-known/jwks.json, which is cached for an hour and fetched again on an unknown kid (at most once every 30 seconds).
  • The signature is checked before any claim is read.
  • iss must be https://api.tassyir.io/apps and aud your client id.
  • exp, nbf and iat allow the backend’s 30 seconds of leeway, and a token may live at most 60 seconds.
It returns the user, business, installation, session and locale. An ID token is a bearer credential for about a minute: accept it over HTTPS only.

Webhooks

tassyir.verifyWebhook({ headers, body }, { installationId }) (or verifyWebhook, or verifyWebhookRequest(request, …) for a Fetch Request) checks a delivery in this order:
  1. The v1a Ed25519 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 from GET /apps/webhook-keys and are fetched again when no key verifies.
  2. The timestamp, within 5 minutes of now.
  3. The envelope’s appId (yours) and installationId (your check: an id, a list, or a function).
  4. The unsigned tassyir-* headers must agree with the signed envelope.
If the keys cannot be fetched once the cached copy has expired, verification throws 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 TassyirApiError with code, fix, requestId, status, retryAfterSeconds, rateLimit and the whole problem. 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, or false to send none.
  • Rate limits. Every answer’s RateLimit-* headers reach onResponse and ApiResponse.rateLimit.
  • Retries are off by default; turn them on with retries: 3. When on, the SDK waits Retry-After and retries, writes included, 429 RATE_LIMITED (the installation’s bucket), 429 BUSINESS_RATE_LIMITED (the ceiling all the business’s apps share), 429 BULK_EXPORT_RATE_LIMITED (5 bulk exports per business an hour) and 503 API_BUSY (the API was too busy and did nothing); a Retry-After longer than maxDelayMs (30 s by default) throws at once. It retries 409 IDEMPOTENCY_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 401 APP_TOKEN_INVALID gets a new token and one more try. It never retries 429 IMAGE_QUOTA_REACHED (POST /images): a daily quota is not worth retrying, so it throws at once and the error’s problem says which quota is full (per, window: day or minute, limit, retryAfter), for the app to wait or stop. It also never retries 503 BULK_EXPORTS_NOT_CONFIGURED (exports are off on that Tassyir environment) or 409 BULK_EXPORT_IN_PROGRESS (wait for bulk/finished, or read GET /bulk/exports/{id}).
  • Pagination. admin.paginate(path, { query }) walks the keyset cursors and yields items.
  • fields takes 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, never parseFloat.

Errors

Everything the SDK throws is a TassyirError. 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 the fetch option and the accessToken provider of AdminClient.

Updating the API types

The OpenAPI document is vendored in openapi/ 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.