# Partner API

> Every call your app's server can make to Facia (coins, scores, badges, group posts, push, missions) with bodies, responses, errors and limits.

# Partner API

Your app's **server** calls Facia to change things inside the business: charge or pay coins, post a score, give a badge, post in a group, send a notification, or count toward a mission. Each call is scoped to one business, needs a permission the business ticked, and is written to the business's activity log.

## Basics

```http
POST https://site.facia.chat/partner/v1/<call>
Authorization: Bearer fas_…
Content-Type: application/json

{ …body… }
```

- **Base address**: the `FACIA_API` value from **Copy as .env** in HQ.
- **Auth**: your app secret (`fas_` + 43 characters) as a Bearer token. Server-side only.
- **Body**: JSON. An empty body counts as `{}`.
- **Response**: always JSON, `Cache-Control: no-store`. Success has `"ok": true`. Failure has `"ok": false` (or just `error`) with an `error` code.
- CORS is open (`*`), but **never call it from the browser**: that would expose your secret.

### Calling it

```js
async function facia(call, body) {
  const res = await fetch(`${process.env.FACIA_API}/${call}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.FACIA_APP_SECRET}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (res.status === 429) await new Promise((r) => setTimeout(r, data.retryAfterMs));
  return data;
}
```

```bash
curl -X POST "$FACIA_API/coins.balance" \
  -H "Authorization: Bearer $FACIA_APP_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"sub":"cl_7f3aQ2xY9pLm0KdE"}'
```

## Permissions

The business ticks these under **What it may do** when registering your app. Each call needs its own.

| Permission (HQ label) | Call |
| --- | --- |
| Take coins | `coins.charge` |
| Give coins | `coins.pay` |
| See a balance | `coins.balance` |
| Post scores | `board.score` |
| Give badges | `badge.give` |
| Post in groups (+ which groups) | `group.post` |
| Send notifications | `push.send` |
| Count mission events | `mission.event` |

## Calls

### coins.charge

Takes coins from a client. Fails if they don't have enough.

```json
{ "sub": "cl_…", "amount": 50, "reason": "Raffle entry", "ref": "raffle-123:cl_…" }
```

```json
{ "ok": true, "balance": 190, "amount": 50, "ref": "raffle-123:cl_…" }
```

### coins.pay

Gives coins to a client. They get a coins card in their support chat.

```json
{ "sub": "cl_…", "amount": 100, "reason": "Trivia winner", "ref": "trivia-2026-10-17:cl_…" }
```

```json
{ "ok": true, "balance": 340, "amount": 100, "ref": "trivia-2026-10-17:cl_…" }
```

**For both coin calls:**

- `amount`: a whole number above 0.
- `reason`: up to 120 characters, shown to the client and in the activity log.
- `ref`: **required**, up to 100 characters. It makes the call **idempotent**: the same `ref` twice is one charge, and the second call gets the first answer back with `"repeat": true`. Refs are shared between `coins.charge` and `coins.pay` for your app at that business. Make them unique per action, e.g. `raffle-123:cl_…`.
- Coins work only while the business has **Client coins** on, and only for **Verified and VIP** clients.

### coins.balance

```json
{ "sub": "cl_…" }
```

```json
{ "ok": true, "balance": 240, "member": true }
```

`balance` is `0` when coins are off or the client isn't a member. Not logged.

### board.score

Posts a score to a named board on the business's leaderboard. The **best** value per client per period is kept.

```json
{ "sub": "cl_…", "board": "trivia", "value": 870 }
```

```json
{ "ok": true, "board": "trivia", "value": 870, "best": 920, "rank": 3, "period": "week" }
```

- `board`: up to 40 characters. `value`: a number, rounded to 2 decimals.
- Needs the business's **Leaderboard** on. Only clients who can be ranked (not staff, not Spam) count.
- `rank` counts up to 100 places above them.

### badge.give

Gives a badge, shown on the client's membership. Each badge key is given once.

```json
{ "sub": "cl_…", "badge": "trivia-champ", "label": "Trivia champ", "icon": "🏆" }
```

```json
{ "ok": true, "badge": "trivia-champ", "given": true }
```

`badge` (up to 40) is lowercased, and anything other than letters, numbers, `-` and `_` becomes `-`. `label` (up to 40) and `icon` (an emoji, up to 8 characters) are what's shown. `given` is `false` if they already had it.

### group.post

Posts a message in a group or channel, as the business. Only to groups the business chose for your app (up to 20).

```json
{ "group": "<chat id>", "text": "🎉 This week's trivia champion is Silver Fox!" }
```

```json
{ "ok": true, "messageId": "…" }
```

`text` up to 2,000 characters. Photos aren't supported yet.

### push.send

Sends a notification to a client's devices (up to 10).

```json
{ "sub": "cl_…", "title": "Your raffle ticket won!", "text": "Tap to claim your prize.", "open": "app" }
```

```json
{ "ok": true, "devices": 2 }
```

`title` up to 80 characters and `text` up to 160 (at least one of them). `open`: `"app"` (default, opens your tab) or `"chat"` (opens their support chat). Extra limit: 120 an hour per app per business, bursts of 20.

### mission.event

Counts toward the business's [missions](https://docs.facia.chat/business/blocks#mission) whose trigger is **An app reports it** with the same event name.

```json
{ "sub": "cl_…", "event": "trivia_played", "n": 1 }
```

```json
{ "ok": true, "event": "trivia_played", "counted": true }
```

`event` up to 40 characters, lowercased. `n` is a whole number of 1 or more (default 1). `counted` is `false` when no running mission uses that event for this client.

## Errors

| HTTP | `error` | Means |
| --- | --- | --- |
| 400 | `bad_json` | The body isn't JSON |
| 400 | `ref_required`, `amount_invalid` | Coin calls: missing ref, or amount not a whole number above 0 |
| 400 | `board_required`, `value_invalid` | `board.score` input |
| 400 | `badge_required` | `badge.give` input |
| 400 | `text_required` | `group.post` with no text, or `push.send` with no title and no text |
| 400 | `event_required`, `n_invalid` | `mission.event` input |
| 401 | `unauthorized` | Wrong secret, the app is off, the business turned apps off, or Facia blocked the app. Deliberately all the same |
| 403 | `permission_missing` | The business didn't tick that permission (`permission` says which) |
| 403 | `group_not_allowed` | Not one of the groups the business chose |
| 404 | `unknown_call` | No such call |
| 404 | `client_unknown` | No client with that `sub` at this business |
| 409 | `short` | Not enough coins (`balance` included) |
| 409 | `coins_off`, `leaderboard_off` | The business has that feature off |
| 409 | `client_not_member` | A guest (no coins). `tier` included |
| 409 | `client_not_ranked` | Staff or Spam can't be on the board |
| 409 | `group_unavailable` | That group can't be posted in right now (for example it was deleted) |
| 409 | `refused` | A rule inside Facia said no (`message` says why) |
| 429 | `rate_limited` | Slow down. Wait `retryAfterMs` |
| 500 | `refused` | Something went wrong on Facia's side. Retry later (coin calls are safe to retry with the same `ref`) |
| 503 | – | Facia is temporarily unavailable |

## Limits

| Limit | Value |
| --- | --- |
| Calls | 1,200 an hour per app per business, bursts of 120 |
| Push | 120 an hour per app per business, bursts of 20 |
| Groups per app | 20 |
| Apps per business | 20 |

## Activity log

Every write shows in **HQ → Activity** with your app's name: `app.charge`, `app.pay`, `app.score`, `app.badge`, `app.post`, `app.push`, and `app.mission` (only when it counted). The business can see exactly what your app did.
