> ## Documentation Index
> Fetch the complete documentation index at: https://developers.tassyir.io/llms.txt
> Use this file to discover all available pages before exploring further.

# @tassyir/app

> Server SDK for Tassyir apps: token exchange and refresh, ID-token and webhook verification, and a typed Admin API client.

The server side of a [Tassyir](https://tassyir.io) 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

```bash theme={null}
npm install @tassyir/app
```

## Example

An Express server for an app with an embedded page and webhooks. Tokens and
the refresh lock live in Redis ([ioredis](https://github.com/redis/ioredis)).
Secrets come from the environment, never from code.

```ts theme={null}
import express from 'express';
import { Redis } from 'ioredis';
import {
  createTassyirApp,
  isTassyirError,
  TassyirVerificationError,
  type RefreshLock,
  type TokenSet,
  type TokenStore,
  type WebhookEvent,
} from '@tassyir/app';

const redis = new Redis(process.env.REDIS_URL!);

// Where each installation's offline tokens live. Encrypt them at rest in a
// real deployment: they are secrets.
const tokenStore: TokenStore = {
  async get(installationId) {
    const value = await redis.get(`tassyir:tokens:${installationId}`);
    return value ? (JSON.parse(value) as TokenSet) : undefined;
  },
  async set(installationId, tokens) {
    await redis.set(`tassyir:tokens:${installationId}`, JSON.stringify(tokens));
  },
  async delete(installationId) {
    await redis.del(`tassyir:tokens:${installationId}`);
  },
};

// One refresh at a time per installation, across every process.
const refreshLock: RefreshLock = {
  async withLock(key, task) {
    const owner = crypto.randomUUID();
    for (
      let tries = 0;
      !(await redis.set(key, owner, 'PX', 60_000, 'NX'));
      tries += 1
    ) {
      if (tries === 120) throw new Error(`Timed out waiting for ${key}`);
      await new Promise((resolve) => setTimeout(resolve, 500));
    }
    try {
      return await task();
    } finally {
      await redis.eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0",
        1,
        key,
        owner,
      );
    }
  },
};

const tassyir = createTassyirApp({
  clientId: process.env.TASSYIR_CLIENT_ID!, // app_…
  clientSecret: process.env.TASSYIR_CLIENT_SECRET!, // tsy_cs_…
  appId: process.env.TASSYIR_APP_ID!, // the numeric app id from the developer console
  tokenStore,
  refreshLock,
  retries: 3,
});

// Installations are recorded from app/installed. A delivery can arrive before
// it (no ordering): refusing it is fine, Tassyir retries it.
const isOurInstallation = async (id: string, event: WebhookEvent) =>
  event.type === 'app/installed' ||
  (await redis.sismember('tassyir:installations', id)) === 1;

const app = express();

// Webhooks: verify the RAW body, answer within 10 seconds, work afterwards.
app.post(
  '/webhooks',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let delivery;
    try {
      delivery = await tassyir.verifyWebhook(
        { headers: req.headers, body: req.body as Buffer },
        { installationId: isOurInstallation },
      );
    } catch (error) {
      if (isTassyirError(error, 'WEBHOOK_TEST_DELIVERY'))
        return res.sendStatus(200);
      if (error instanceof TassyirVerificationError) return res.sendStatus(400);
      throw error;
    }
    // At least once, in any order: skip a webhook-id you already handled.
    if (
      !(await redis.set(
        `tassyir:seen:${delivery.id}`,
        '1',
        'EX',
        259_200,
        'NX',
      ))
    ) {
      return res.sendStatus(200);
    }
    res.sendStatus(200);

    switch (delivery.event.type) {
      case 'app/installed':
        await redis.sadd('tassyir:installations', delivery.installationId);
        break;
      case 'orders/created':
        console.log(
          'new order',
          delivery.event.data.order.orderNumber,
          delivery.event.data.order.total,
        ); // total: "2500.00"
        break;
      case 'app/uninstalled':
        await redis.srem('tassyir:installations', delivery.installationId);
        await tassyir.tokens.forget(delivery.installationId);
        break;
    }
  },
);

// The embedded page calls this with its App Bridge ID token as a bearer.
app.get('/api/pending-orders', async (req, res) => {
  const idToken = req.get('authorization')?.replace(/^Bearer /, '') ?? '';
  try {
    const { idToken: session } = await tassyir.authenticate(idToken);
    const admin = tassyir.admin(session.installationId);
    const orders = [];
    for await (const order of admin.paginate('/orders', {
      query: {
        status: 'pending',
        limit: 100,
        fields: ['orderNumber', 'total', 'createdAt'],
      },
    })) {
      orders.push(order);
      if (orders.length === 200) break;
    }
    res.json(orders);
  } catch (error) {
    if (error instanceof TassyirVerificationError) return res.sendStatus(401);
    throw error;
  }
});

app.listen(3000);
```

A headless app (no embedded page) gets its first tokens from the install code
that its `auth.callback_url` receives:

```ts theme={null}
app.get('/auth/callback', async (req, res) => {
  const tokens = await tassyir.redeemInstallCode(String(req.query.code)); // stored for you
  res.send(`Installed on business ${tokens.businessId}`);
});
```

## 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:

```ts theme={null}
const { data: order } = await admin.get('/orders/{id}', { path: { id } });
await admin.post('/orders/{id}/confirm', {
  path: { id },
  idempotencyKey: `confirm-${id}`,
});
const created = await admin.request('post', '/orders', { body }); // created.status: 201, or 200 for a duplicate
```

* **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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.