npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@slytrunk/sly-tifo-client

v0.4.0

Published

Typed client for the Sly Tifo loyalty API.

Readme

@slytrunk/sly-tifo-client

A typed client for the Sly Tifo loyalty API: standing, awards, and the collectibles/collections/actions catalog.

Install

npm install @slytrunk/sly-tifo-client

Using React? @slytrunk/sly-tifo-react is released alongside this package with the same version number, so install matching versions of the two.

Quick start

The client never sees your secret key. It only holds a short-lived end-user token, minted by your own server and handed to the client through getToken. Your secret key stays on your server; it never reaches this package, the browser, or anything you ship to a customer.

import { API_BASES, createClient } from '@slytrunk/sly-tifo-client'

const client = createClient({
  apiBase: API_BASES.production,
  async getToken() {
    // Call your own server, which holds the secret key and mints a
    // short-lived token for this end user. This package never sees that key.
    // See "Minting a token on your server" below for that route.
    const res = await fetch('/api/sly-tifo-token')
    return res.json() // { token, expires_at }, exactly as the API sent it
  },
})

const standing = await client.me()

apiBase accepts a trailing slash. Use API_BASES.stage while testing against your stage environment.

Minting a token on your server

getToken needs a route on your own server. That route calls POST /v1/end-user-tokens with your secret key and your own identifier for the signed-in user, and hands the token to the browser.

There is no separate "create user" call. The first time the API sees an external_id, it enrolls that user in your program. After that, the same external_id always means the same user.

// On YOUR server. The secret key must never reach a browser.
const API_BASE = 'https://api.slytifo.com' // or https://api.stage-slytifo.com

app.post('/api/sly-tifo-token', requireSignedIn, async (req, res) => {
  const upstream = await fetch(`${API_BASE}/v1/end-user-tokens`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.SLY_TIFO_SECRET_KEY}`,
      'content-type': 'application/json',
    },
    // Take the id from YOUR session, never from the request. A route that
    // mints for whatever id the browser sends lets anyone act as anyone.
    body: JSON.stringify({ external_id: req.user.id }),
  })

  if (!upstream.ok) {
    // Log the details; don't pass them through to the browser.
    console.error('sly-tifo token mint failed', upstream.status, await upstream.text())
    return res.status(502).json({ error: 'token-unavailable' })
  }

  // `{ token, expires_at }` is exactly what `getToken` returns, so pass it on.
  res.json(await upstream.json())
})

Things that go wrong here:

  • Keep expires_at as it is. The client reads that field. If it goes missing, nothing errors, but the client treats every token as already expired and asks for a new one before every request. (expiresAt, the spelling in 0.1.x, still works but is deprecated.)
  • Build the headers yourself. Don't forward the browser's request headers. A secret key sent along with the browser's Origin header is refused with 403 secret-key-from-browser.
  • Set allowed origins on the key first. A key with none can't mint tokens and gets 422 no-allowed-origins. Set them on the API keys screen.
  • Match the key to the environment. A stage key mints tokens only stage accepts; see Troubleshooting.

Tokens last fifteen minutes, and the client calls getToken again on its own when one is about to expire. So this route runs many times per visit. Keep it to minting only.

Setting a user's name and attributes

Names and attributes, such as a favorite player or a membership number, are set from your server with PATCH /v1/end-users/{externalId}, using the same secret key. A browser token can't set them, so users can't rename themselves or pick their own picture.

The call is the same for new and existing users. If the external_id hasn't been seen yet, the PATCH enrolls the user first. So you can run it at sign-up, and again whenever the data changes in your own system:

// On YOUR server.
async function syncSlyTifoUser(user) {
  const upstream = await fetch(`${API_BASE}/v1/end-users/${encodeURIComponent(user.id)}`, {
    method: 'PATCH',
    headers: {
      authorization: `Bearer ${process.env.SLY_TIFO_SECRET_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      display_name: user.name,
      attributes: {
        favorite_player: user.favoritePlayer, // a declared string attribute
        member_since: user.memberSinceYear, // a declared number attribute
      },
    }),
  })
  if (!upstream.ok) {
    // 422 bodies are { error, message }, and the message names the field.
    throw new Error(`sly-tifo sync failed: ${upstream.status} ${await upstream.text()}`)
  }
}

How the fields behave:

  • Only what you send changes. Attributes you leave out keep their values, and leaving out display_name keeps the name.
  • null clears an attribute. { "attributes": { "favorite_player": null } } removes that attribute. display_name can't be cleared, so null or a blank string there is refused.
  • Declare attributes before writing them. Each attribute has to be declared on the program first, under Attributes on the program's home page in admin, and the value has to match the declared type (string, number, boolean or image URL). Anything else is refused with 422 invalid-payload, and the message names the attribute. Nothing is dropped silently, and a refused request doesn't enroll anyone. Every program starts with two image attributes already declared: avatar and background.
  • A new user's name defaults to their external_id. The token route also accepts display_name, but only uses it when it enrolls someone new, and ignores it for a user who already exists. To set or change a name reliably, use this PATCH.

A typical sign-up runs syncSlyTifoUser first and mints the token afterward. That way the user already has their name and attributes the first time the browser asks for their standing.

The methods

  • client.me() — the signed-in end user's standing (tier, progress, collectibles). options.sets[set].options lists each unlocked option's name and art_url / art_thumb_url, as this user should see them.
  • client.award({ action, payload?, subject?, idempotencyKey? }) — record an event and get back the tier and collectible changes it caused.
  • client.collectibles() — the collectible catalog.
  • client.collections() — the collection catalog.
  • client.actions() — the action catalog.
  • client.attributes() — the program's declared attributes: each one's key, type and label, an enum's values, and an option attribute's set. Build forms and pickers from it.
  • client.optionSets() — the option set catalog: each set's options, their art and the tier or collectible that gates them.
  • client.optionSet(key) — one option set. An unknown key rejects with an ApiError whose status is 404.

Refusals

Some perfectly ordinary outcomes — a daily cap already spent, a season that hasn't opened yet, a ladder with no tier to award — come back as an HTTP 200, not an error. me() and award() return either the real value or a typed Refused, so a refusal can't be mistaken for success just because nothing threw.

import { isRefused } from '@slytrunk/sly-tifo-client'

const result = await client.award({ action: 'watch-game' })

if (isRefused(result)) {
  console.log(`refused: ${result.code}`, result.detail)
} else {
  console.log('awarded', result)
}

A Refused already carries code and detail, split out of reason, so branch on result.code. splitRefusal(reason) does the same split on a raw reason string — reach for it only if you got one some other way.

Errors

Anything that isn't a refusal throws an ApiError, with status and code. A status of 0 and code: 'unreachable' means the request never reached the server at all (no connection, DNS failure, and so on) — there was no HTTP response to read a status from.

import { ApiError } from '@slytrunk/sly-tifo-client'

try {
  await client.me()
} catch (error) {
  if (error instanceof ApiError) {
    console.error(error.status, error.code, error.message)
  }
}

Troubleshooting: a 401 that won't go away

The end-user token you pass in is minted with your secret key, and the key decides which environment the token is good for — production or stage. If apiBase points at the other environment, every request for that token fails with 401. The client already retries once with a fresh token before giving up, so a 401 that survives one retry is not a fluke: it means the key that minted the token and the apiBase you're calling don't agree on environment.

This is easy to miss because both environments issue keys that look the same — sk_live_… is not a production-only prefix. If you're getting a persistent ApiError(401), check that the key your server used to mint the token and the apiBase you passed to createClient are for the same environment.

React

Building a React app? See @slytrunk/sly-tifo-react for <SlyTifoProvider> and a set of hooks built on this client.