# Lookup Presence

Read the live Discord presence of a member of the haunt.gg server.

Read the live Discord presence of a member of the haunt.gg Discord server by
their account snowflake: status, current activity, profile media (avatar,
banner, decoration, nameplate), server nickname, bio, guild tag and Discord
badges — the same payload that powers the Discord presence widget on a profile.

**GET** `/api/lookup/presence/{discordId}`

> [!NOTE]
> Presence is only visible for members of the [haunt.gg Discord
> server](https://discord.gg/hauntbio) — that is where the bot observes it. A
> Discord account that exists but has not joined answers `404` with
> `{"miss":"notMember"}`, so you can tell "not a member" apart from "no such
> account" without a second request.

## Authorizations

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `X-API-Key` | string | header | yes | API key from your dashboard with the `lookup:presence` permission (granted by staff). Treat it like a password. |

## Path Parameters

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `discordId` | string | path | yes | The Discord account id (snowflake) to resolve — 17 to 19 digits. Anything else is rejected with `400` before the lookup runs. This is a Discord id, not a haunt.gg `uid`: to go the other way, resolve the haunt.gg user first with `GET /api/lookup/user?type=username&value=…` and read `user.discordId`. |

## Example Request

  #### cURL

```bash
curl -X GET "https://haunt.gg/api/lookup/presence/1363890885443452989" \
  -H "X-API-Key: YOUR_API_KEY"
```
  #### JavaScript

```ts
const discordId = "1363890885443452989";

const res = await fetch(`https://haunt.gg/api/lookup/presence/${discordId}`, {
  method: "GET",
  headers: { "X-API-Key": apiKey },
});
const data = await res.json();
```
  #### Python

```python
import requests

discord_id = "1363890885443452989"

res = requests.get(
    f"https://haunt.gg/api/lookup/presence/{discord_id}",
    headers={"X-API-Key": api_key},
)
data = res.json()
```

## Status values

`status.status` is the member's effective status, collapsed across their
devices — the first non-offline of desktop, mobile and web. Streaming wins over
everything: a member who is live reads `streaming` no matter what their manual
status says.

| Value | Meaning |
| --- | --- |
| `online` | Active on at least one device. |
| `idle` | Away — set manually or by inactivity. |
| `dnd` | Do Not Disturb. |
| `streaming` | Has a Streaming activity running. |
| `offline` | Offline, invisible, or has no presence the bot can see. |

`status.url` is a ready-to-embed icon on `https://assets.haunt.gg/status/…`,
one PNG per value above.

## Activities

`activity` is the single activity worth showing, picked in this order: Spotify
first, then Playing / Streaming / Watching, then any other Listening activity,
then a Custom Status. `activities` carries every activity the member has
running, unfiltered and in Discord's own order — `activity` is always one of
them (or `null` when there are none).

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `name` | string |  | no | Activity name — the game, `Spotify`, or the app. Empty for a plain custom status with no text. |
| `type` | number |  | no | Discord's activity type: `0` Playing, `1` Streaming, `2` Listening, `3` Watching, `4` Custom Status, `5` Competing. |
| `state` | string | null |  | no |  |
| `details` | string | null |  | no | The activity's details line — for Spotify, the track title. |
| `tooltip` | string | null |  | no | Hover text of the activity's large (or small) asset — for Spotify, the album name. |
| `emoji` | string | null |  | no | Custom-status emoji. A CDN URL for a custom emoji, the literal unicode character for a standard one, `null` when there is none. |
| `image` | string | null |  | no | Large activity artwork — album cover, game art or rich-presence asset — resolved to an absolute CDN URL. `null` when the activity ships none. |

## Badges

`badges` is an object keyed by badge id, ordered the way the presence widget
renders it. Every entry carries a display `name` and a ready-to-embed `url` on
`https://assets.haunt.gg/badges/discord/…`. Only badges the account actually
holds are present, so iterate the object rather than indexing fixed keys.

Possible keys, in canonical order: `nitro`, `staff`, `partner`, `moderator`,
`events`, `bravery`, `brilliance`, `balance`, `bughunter1`, `bughunter2`,
`developer`, `earlysupporter`, `boosted`, `legacy`, `quest`, `leaf`, `orb`,
`gifting`.

## Response

  #### 200 — The member's presence. Every key is always present; the ones that do not apply to this account are null (or an empty object / array).

```json
{
  "id": "1363890885443452989",
  "username": "john",
  "globalName": "John",
  "serverNick": "john the third",
  "avatar": "https://cdn.discordapp.com/avatars/1363890885443452989/a1b2c3d4e5f6.png",
  "serverAvatar": null,
  "banner": "https://cdn.discordapp.com/banners/1363890885443452989/f6e5d4c3b2a1.png",
  "serverBanner": null,
  "decoration": "https://cdn.discordapp.com/avatar-decoration-presets/a_9f8e7d6c5b4a.png",
  "nameplate": "https://cdn.discordapp.com/assets/collectibles/nameplates/twilight/asset.webm",
  "biography": "just here for the music",
  "activity": {
    "name": "Spotify",
    "type": 2,
    "state": "by Burial",
    "details": "Archangel",
    "tooltip": "Untrue",
    "emoji": null,
    "image": "https://i.scdn.co/image/ab67616d0000b273a1b2c3d4e5f6"
  },
  "activities": [
    {
      "name": "Spotify",
      "type": 2,
      "state": "by Burial",
      "details": "Archangel",
      "tooltip": "Untrue",
      "emoji": null,
      "image": "https://i.scdn.co/image/ab67616d0000b273a1b2c3d4e5f6"
    },
    {
      "name": "Custom Status",
      "type": 4,
      "state": "building things",
      "details": null,
      "tooltip": null,
      "emoji": "https://cdn.discordapp.com/emojis/1234567890123456789.png",
      "image": null
    }
  ],
  "badges": {
    "nitro": {
      "name": "Discord Nitro",
      "url": "https://assets.haunt.gg/badges/discord/premium.png"
    },
    "boosted": {
      "name": "Server Boosting since 12 Jan 2025",
      "url": "https://assets.haunt.gg/badges/discord/guild_booster_lvl9.png"
    }
  },
  "status": {
    "status": "online",
    "url": "https://assets.haunt.gg/status/online.png"
  },
  "last_online": 1756400000,
  "clan": {
    "tag": "HNT",
    "badge": "https://cdn.discordapp.com/guild-tag-badges/1363890885443452989/a1b2c3d4.png"
  }
}
```
  #### 400 — The path segment is not a Discord snowflake (17–19 digits). Returns a stable error key.

```json
{
  "error": "errors.lookupDiscordIdInvalid"
}
```
  #### 401 — The API key is missing, unknown, expired, over its usage quota, or lacks the lookup:presence permission. The first body is the missing-header case; every other case passes the auth layer's own message and code through — the codes you can see are INVALID_API_KEY, KEY_NOT_FOUND (which is also what a missing permission looks like), KEY_EXPIRED and USAGE_EXCEEDED.

```json
{
  "error": "Missing x-api-key header.",
  "code": "UNAUTHORIZED"
}
```
```json
{
  "error": "API Key not found",
  "code": "KEY_NOT_FOUND"
}
```
  #### 403 — Your API key was disabled.

```json
{
  "error": "API Key is disabled",
  "code": "KEY_DISABLED"
}
```
  #### 404 — The presence could not be resolved. The machine-readable miss field says why: notMember means the Discord account exists but has not joined the haunt.gg server, notFound means no Discord account with that id exists at all.

```json
{
  "error": "That Discord account has not joined the haunt.gg server, so its presence is not visible.",
  "miss": "notMember"
}
```
```json
{
  "error": "No Discord account found.",
  "miss": "notFound"
}
```
  #### 429 — Too many requests. The first body comes from the per-key limiter; the second from the per-IP limiter, which sits in front of everything and also sends Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.

```json
{
  "error": "Rate limit exceeded.",
  "code": "RATE_LIMITED"
}
```
```json
{
  "error": "errors.rateLimitExceeded"
}
```

## Rate limits

Two buckets apply at once:

| Bucket | Limit |
| --- | --- |
| Per API key | 30 requests/minute, or 60 if the key's owner has premium. |
| Per IP address | 60 requests/minute across all of `/api/*`. |

A free-tier key is therefore effectively capped at 30 requests per minute.

## Things worth knowing

> [!NOTE]
> - **Freshness.** Status and activities are read live on every request. The
>   member record behind the names, avatars, banner and guild tag is cached for
>   30 seconds, and the Discord profile behind `biography` and the external
>   badges (Nitro, Server Booster, Quest, …) for 24 hours — a fresh Nitro
>   subscription can take up to a day to appear here.
> - **Server profile vs. account profile.** `serverNick`, `serverAvatar` and
>   `serverBanner` are the member's haunt.gg-server profile and are `null` for
>   most members. Fall back to `globalName` → `username` for the name and to
>   `avatar` / `banner` for the media, which is what the profile widget does.
> - `last_online` is a Unix timestamp in **seconds**, recorded by the bot when
>   the member last went offline. It is `null` for a member who has never been
>   seen going offline.
> - `nameplate` is a WebM video, not an image — render it in a muted, looping
>   `<video>` rather than an `<img>`.
> - `clan` is the member's Discord guild tag (the short badge next to their
>   name), `null` when they display none.
> - Any `/api/*` request that reaches the origin without Cloudflare's
>   `cf-connecting-ip` header is refused with `403` and
>   `{"error":"errors.forbidden"}` before the route runs. This only affects
>   callers that bypass Cloudflare.
