# FlipKey Watch-Drop Handoff SDK

Server-side Node helper (Node 20+, **zero dependencies**; Node 22 LTS recommended) to receive and fulfill
FlipKey watch-drop claims. This is the publisher-side half of a Twitch watch-drop:
viewers watch your stream → earn a tier → claim (FlipKey provisions them a wallet)
→ **FlipKey hands the claim off to you** so you grant the in-game item.

## Install

Copy `flipkey-watchdrop.js` into your backend (or vendor this folder). No npm
package required.

## The flow

```
viewer watches stream → crosses a tier → claims  (FlipKey: wallet provisioned = the signup)
        │
        ▼
FlipKey POSTs a SIGNED handoff to your webhook  → you grant the item → you ack
```

## Minimal integration

```js
const express = require("express");
const flipkey = require("./flipkey-watchdrop");

app.post(
  "/flipkey/watchdrop",
  express.raw({ type: "application/json" }),     // ← REQUIRED for signature verify
  flipkey.expressHandler({
    secret: process.env.FLIPKEY_WEBHOOK_SECRET,   // from dashboard → Webhooks
    apiKey: process.env.FLIPKEY_API_KEY,          // to ack fulfillment
    onClaim: async (claim) => {
      const player = await myDb.playerByFlipkeyAccount(claim.flipkey_account_id);
      await myGame.grantItem(player, claim.reward_drop_id);   // idempotent on claim.redemption_id!
      return `granted:${claim.redemption_id}`;                // opaque receipt
    },
  })
);
```

Then set your webhook URL to `https://yourgame.com/flipkey/watchdrop` on the
dashboard **Webhooks** page. That's it. See `examples/handoff-server.js` for a
complete runnable server.

## What you receive (`claim`)

Viewers claim a **soulbound ticket** on FlipKey; the grant fires when they
**redeem** it in their library (burn → this webhook). So the event you act on
is `item_ticket.redeem`:

| field | meaning |
|---|---|
| `event` | `"item_ticket.redeem"` (current rail; the SDK also still accepts legacy `"watchdrop.claim"` handoffs) |
| `redemption_id` | opaque id — **use as your idempotency key** |
| `reward_drop_id` | the reward to grant |
| `item_name` | display name of the item |
| `flipkey_account_id` | **stable account key** — map this to your player |
| `wallet_address` | same value (the player's FlipKey wallet on Base) |
| `amount` | units redeemed (1) |
| `burn_tx_hash` | on-chain burn transaction of the consumed ticket |
| `connected` | `true` — connection is a redeem precondition |

FlipKey **never** sends a raw game/player id. You hold the
`flipkey_account_id → your player` map (stored once at "Connect &lt;Game&gt;", or
matched via `twitch_user_id`). See the privacy model in
`docs/watch-drop-passthrough-broker.md`.

## Security

- Every handoff carries `X-FlipKey-Signature` = `HMAC-SHA256(rawBody, webhook_secret)` (hex).
  The SDK verifies it for you — **but only if you give it the raw body**, hence
  `express.raw({ type: "application/json" })` on the route (not `express.json()`).
- Verify on EVERY request; reject mismatches (the SDK returns 401).
- Treat `onClaim` as **idempotent** — FlipKey retries on any non-2xx response.

## Don't assume game ownership

Item tickets redeem **without owning the game** — that's the acquisition
funnel working, not an error (a viewer holding your skin is a buyer-to-be).
By the time the handoff reaches you the ticket is already **burned**, so
never reject a claim because the account doesn't own the game: respond
`200`, park the grant in the account's inventory (idempotently), and let
the game pick it up whenever they show up. A non-2xx response just makes
FlipKey retry the delivery — the player's ticket stays spent either way.

## API

- `verifySignature(rawBody, signatureHeader, secret) → boolean`
- `parseClaim(rawBody, signatureHeader, secret) → {ok, claim} | {ok:false, status, error}` — framework-agnostic verify+parse
- `ackFulfilled({ baseUrl?, apiKey, claimId, receipt? })` — mark `fulfilled` + store your receipt
- `expressHandler({ secret, onClaim, apiKey?, baseUrl?, autoAck? })` — the Express convenience wrapper (auto-acks when `apiKey` is set)

## Key Station Plus test

The dashboard's Plus test sends an `item_ticket.redeem` with `test: true`
(redemption_id `plustest_…`). `expressHandler` (v0.2+) acks it without calling
your `onClaim`, which turns Key Station Plus on. Rolling your own handler? Skip
the grant for `test: true` and ack the redemption_id as usual.

## Acking

Returning a receipt from `onClaim` (with `apiKey` set) auto-acks fulfillment, so
the dashboard shows the claim as **fulfilled**. To ack manually, call
`ackFulfilled({ apiKey, claimId, receipt })`. Acking is optional — responding
`200` already confirms delivery — but it gives you a clean delivered-vs-fulfilled
view and stores your proof-of-delivery receipt.
