# Integrating deal-app

Three ways to put a game in front of a customer, in rising order of effort. Every one of them
ends the same way: the server decided the outcome before any animation ran, and a winner holds a
reward code your side can take at checkout.

Outbound webhooks — the receiver contract for the events deal-app posts to you — are
covered in **Outbound webhooks** below.

## Pick a delivery mode

| Mode | Who it is for | What you write |
|---|---|---|
| **Hosted link or QR** | A shop with no developer | Nothing. Create a session in the dashboard and share the link |
| **Website pop-up** | A shop with a website | One `<script>` tag |
| **Inline iframe** | A team that wants the game inside a page | One `<iframe>` tag |
| **API** | A team with a backend and its own accounts | Two HTTP calls |

### Hosted link or QR

Create a configuration, publish it, then create a **session** from it. A session is one timed run
with its own public URL of the form `/play/<token>` and its own analytics. Share the URL or its QR
code. When the session expires the link shows a designed expired state.

### Website pop-up

Create an API key on **Integrations**, then paste the loader once, anywhere on the page:

```html
<script async src="https://deal.example/w.js" data-key="pk_live_..."></script>
```

The `pk_live_` public key is safe in a page. The loader reads your campaign's pop-up rules (delay,
scroll depth, exit intent, audience share, shows per visitor per day, path prefixes) and opens the
game in an overlay. `window.dealApp.open()` opens it on demand, for a button of your own.

**On a React, Next.js or Vue site the tag above is the wrong line.** A plain `<script>` written
inside a component is never executed on the client — it survives only the server-rendered pass, so
the pop-up appears on a first load and silently stops appearing after any client-side navigation.
Load it the way your framework loads scripts. On Next.js:

```tsx
import Script from 'next/script';

<Script src="https://deal.example/w.js" data-key="pk_live_..." strategy="afterInteractive" />
```

On Shopify, Wix or WordPress the plain tag is right, pasted into the theme's custom-code box
(Shopify: `theme.liquid`, before the closing body tag. Wix: Settings → Custom code → body end).

**Checking your own install.** Your pop-up obeys its own rules the moment it is live, including
"once per visitor per day", so the first look is otherwise also the last one for 24 hours. Add
`?deal_preview=1` to any page of your site and the loader ignores every visitor rule — delay,
scroll, audience share, path list and the daily count — and opens at once, without counting the
show. Only a request carrying that flag behaves this way, so your visitors are unaffected. The
dashboard builds the URL for you under **My website → Check it on your site**.

If nothing appears, the loader says why in the browser console: an unknown key, a game whose
pop-up is off, or an unreachable origin. It never shows your visitors an error.

List the sites allowed to use the key on **Integrations → Allowed sites**. An empty list means any
site, which is convenient on day one and worth closing before launch.

### Inline iframe

```html
<iframe src="https://play.deal.example/en/embed/<company>/<campaign>"
        width="100%" height="720" style="border:0" title="Spring promotion"></iframe>
```

Framing is authorized **per request** against your allowed-site list. A page that is not on the
list gets a 404, not a broken frame, and the `frame-ancestors` policy on the response names your
origins. Add the site to the list before you embed; no redeploy is involved.

Opening that embed address in a browser tab sends you to the game's permanent `/c` link instead,
because an embed address is only meaningful inside a frame. That is the address to test with.

## Messages the frame posts to your page

The embed posts to the exact origin it was framed from, never to a wildcard.

| Type | When | Extra fields |
|---|---|---|
| `deal:ready` | The frame has mounted | |
| `deal:resize` | The content height changed | `height` |
| `deal:play_started` | The player triggered a play | |
| `deal:result` | The result is on screen | `outcome`, `prizeLabel` |
| `deal:claim_required` | The win needs a verified contact first | |
| `deal:claimed` | The player finished the claim gate | |
| `deal:error` | The play was refused or failed | |

```js
window.addEventListener('message', event => {
  if (event.origin !== 'https://play.deal.example') return;
  if (event.data?.type === 'deal:resize') frame.style.height = `${event.data.height}px`;
});
```

**Never grant anything from a `postMessage`.** A host page can post any message to itself. Entitlements
come from a webhook or from the authenticated API, and nothing else. No message carries a reward code.

## The API

Base URL is your dashboard origin. Authenticate with the secret key from **Integrations**:

```
Authorization: Bearer sk_live_<key id>_<secret>
```

The secret is shown once, at creation. It is rejected outright on any request that carries an
`Origin` header, so a key that leaks into a browser is useless.

**Build against a test key first.** A key is issued as either **live** or **test**, and a test key
is `sk_test_...`. It reads exactly what a live key reads — real campaigns, real reward codes, real
analytics. What it never does is write: `use`, `undo-use` and `cancel` run every check the live
route would, against the real reward, and answer what *would* have happened without changing
anything:

```json
{ "simulated": true, "intent": "use", "code": "8PEP-...", "wouldSucceed": true, "reason": null }
```

The code is still spendable afterwards, so you can run your checkout end to end as many times as
you like. `simulated` is always present on those answers, so nothing in your code can mistake a
rehearsal for a real hand-over. Swap the key for the live one when you ship; nothing else changes.

**Test mode covers the API, not the game.** A game that opens issues a real reward against a real
budget — there is no rehearsal for a play. So the pop-up loader and the embed need a **live**
public key; a `pk_test_` in a page is refused with the same answer an unknown key gets. Test the
game with a real campaign whose budget you set low, or pause it when you are done.

| Method and path | Purpose |
|---|---|
| `POST /api/v1/tokens` | Mint a short-lived identity token for one of your own users |
| `GET /api/v1/campaigns` | List your configurations |
| `POST /api/v1/sessions` | Create a timed session from a configuration |
| `GET /api/v1/sessions` | List sessions |
| `GET /api/v1/sessions/{id}/stats` | Customers, plays, winners and rewards for one session |
| `GET /api/v1/analytics/campaigns/{id}` | Plays, wins, observed win rate, rewards issued and used, budget, and the order value those uses carried |
| `GET /api/v1/rewards/{code}` | Ask what a code is worth, without spending it |
| `POST /api/v1/rewards/use` | Mark a reward used, after the order is paid |
| `POST /api/v1/rewards/undo-use` | Give a code back when the order was cancelled |
| `POST /api/v1/rewards/cancel` | End a reward for good and return its budget |
| `GET /api/v1/rewards` | Page the whole reward ledger, to reconcile against your orders |

Public, called by the game surface rather than by you: `POST /api/v1/session`,
`POST /api/v1/play`, `GET /api/v1/campaign/{company}/{campaign}`, `GET /api/v1/widget/config`,
and the reward claim routes.

### Take a code at your checkout

Two calls, in this order. The first says what the code is worth. The second spends it.

**1 — Check the code** when the customer types it into your promo field:

```bash
curl https://deal.example/api/v1/rewards/8PEP-2QJJ-STGA-H219-KCQ7-YFRF-7K8Q \
  -H "Authorization: Bearer sk_live_..."
```

```json
{
  "code": "8PEP-2QJJ-STGA-H219-KCQ7-YFRF-7K8Q",
  "kind": "percent",
  "sku": null,
  "percentOff": 10,
  "amountOffCents": null,
  "label": { "en": "10% off", "ka": "10% ფასდაკლება" },
  "status": "claimed",
  "usable": true,
  "reason": null,
  "expiresAt": "2026-09-13T09:00:00.000Z"
}
```

Switch on `kind`. A `percent` reward carries `percentOff` (a whole percent: `10` means 10% off) and
a `fixed` reward carries `amountOffCents` (minor units, like every other amount in this API).
`free_item`, `custom` and `consolation` carry neither — show `label` and decide yourself.

Every reward also carries `sku`: whatever the shop typed into **Your product code** on the prize,
verbatim. deal-app never interprets it. Set it to your own product id or coupon id and a free-item
prize becomes something your checkout can act on rather than a line of text for a human to read.

`usable: false` means do not discount the cart. `reason` says why: `already_used`, `expired`,
`awaiting_claim` (the winner has not confirmed a contact yet) or `void`. An unknown code answers
404 `REWARD_NOT_FOUND` — the same answer another tenant's real code gets, so the endpoint cannot be
used to probe which codes exist.

**2 — Mark it used**, once the payment is captured:

```bash
curl -X POST https://deal.example/api/v1/rewards/use \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"code":"8PEP-2QJJ-STGA-H219-KCQ7-YFRF-7K8Q","orderValueCents":4200}'
```

**Order matters.** Check at the cart, mark used after the money arrives. Marking used at the cart
burns the code on every abandoned checkout, and there is no way to give it back.

Marking used is a single guarded transition, so two tills scanning the same code at once produce
exactly one use. A second call answers `REWARD_NOT_USABLE`. `orderValueCents` is stored on the
reward so attribution is captured from the first use. It is not summarised by the analytics
endpoint yet, so read it from the dashboard winners table for now.

Both routes take the company from the secret key, never from the request, and every reward query is
tenant scoped: a key can only ever see and spend its own company's codes. Neither route accepts a
request carrying an `Origin` header, so this must run on your server — a key in front-end
JavaScript is a leaked key.

A code can also be marked used from the dashboard, or by counter staff through a PIN-protected link
that exposes nothing else.

### Retrying safely

A checkout that times out cannot tell whether its `use` call landed. Send a `requestId` — anything
unique to that attempt, up to 128 characters — and the retry is answered instead of refused:

```bash
-d '{"code":"8PEP-...","orderValueCents":4200,"requestId":"order-55123-attempt-1"}'
```

The id is stored on the reward when it is spent. A retry carrying the same id gets `200` and the
same reward back. A *different* caller — a second till, a customer trying twice — still gets
`REWARD_NOT_USABLE`, which is the whole point: your own retry is safe, someone else's is not.

`undo-use` and `cancel` need no such id. Retrying them is harmless: a code that is already back is
still back, and a cancelled reward is still cancelled.

### When an order is cancelled

Two different things can happen to an order, and they are not the same call.

| What happened | Call | Effect on the code | Effect on your budget |
|---|---|---|---|
| Order cancelled or refunded, customer got nothing | `POST /api/v1/rewards/undo-use` | Spendable again, back to the state it held before | Spend returns to reserved |
| The reward itself should end — fraud, a withdrawn promotion | `POST /api/v1/rewards/cancel` | Dead for good | Returned to the budget |

```bash
curl -X POST https://deal.example/api/v1/rewards/undo-use \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"code":"8PEP-2QJJ-STGA-H219-KCQ7-YFRF-7K8Q"}'
```

Undo puts a reward back where it was: one that had been claimed returns to `claimed`, one that had
not returns to `issued`. Both calls are guarded the same way a hand-over is, so two of them at once
produce exactly one effect. Neither un-counts a winner — the lifetime winner count only ever goes
up, by design, so a cancelled reward does not hand its campaign a free extra win.

Expiry is final: an expired reward cannot be cancelled or given back.

### Reconciling against your own orders

```bash
curl "https://deal.example/api/v1/rewards?status=used,cancelled&limit=200" \
  -H "Authorization: Bearer sk_live_..."
```

Answers `{ "items": [...], "nextCursor": "..." }`. Pass `nextCursor` back as `cursor` until it is
`null`. Paging is by id rather than by offset, so rewards issued while you walk the ledger cannot
shift a row past you. `status` is optional and takes any of `issued`, `claimed`, `used`, `expired`,
`cancelled`. Every row carries `usedAt`, `cancelledAt` and `orderValueCents`, which is enough to
match against your own order table.

## Identity: who is allowed to play, and what a per-person cap means

Set this per campaign. It is the single most important choice for anyone worried about a script
farming rewards.

| Mode | Who plays | Is "one play per person" enforceable |
|---|---|---|
| `anonymous_claim` | Anyone | **No.** The count is per browser. Clearing cookies earns another play |
| `account_required` | Signed-in deal-app players | **Yes**, per player account |
| `company_signed` | Users your backend vouches for | **Yes**, per reference you sign |

Under anonymous play the honest ceilings are the ones that bound money rather than people: maximum
winners, budget, wins per day, and the per-campaign play-rate limit. Set those as if every visitor
will try twice, because some will.

### Wiring `company_signed`

Your backend already knows who the user is. Mint a token for them, hand it to the page, and the
game counts caps against your reference.

```js
const minted = await fetch('https://deal.example/api/v1/tokens', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.DEAL_APP_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ playerRef: user.id }),
}).then(response => response.json());
```

The token lives five minutes and carries only your company and that reference. Pass it when the
game opens a round:

```js
await fetch('/api/v1/session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    companySlug: 'your-company',
    campaignSlug: 'spring',
    identityToken: minted.identityToken,
  }),
});
```

A token signed for one company is ignored by every other company's campaign. Mint it server-side
only: it is a bearer credential for one identity, and minting it needs your secret key.

## What is decided where

- **The server rolls the outcome** before the transaction, against the configured win rate and the
  live caps. The animation only reveals what already happened. No client input influences a result.
- **Caps and budget move in one guarded update**, so concurrent plays cannot overspend a budget or
  exceed a winner cap.
- **A play token is single use.** Replaying one is refused.
- **`Idempotency-Key` on a play** returns the stored outcome instead of rolling again, so a lost
  response never loses a win.
- **Plays are metered per month** against the plan. Past the grace ceiling live campaigns pause
  rather than overspending, and no surprise overage is charged.

## Errors

Public surfaces answer a short, generic code rather than the internal reason: echoing which field
was wrong tells an attacker what to change. Codes a player surface may show, because they are
actionable, include `RATE_LIMITED`, `PLAYS_EXHAUSTED`, `COOLDOWN_ACTIVE`, `CAMPAIGN_NOT_LIVE`,
`CAMPAIGN_SUSPENDED`, `OUTSIDE_SCHEDULE`, `BLOCKED_COUNTRY`, `IDENTITY_REQUIRED`,
`COMPANY_IDENTITY_REQUIRED`, `AGE_NOT_CONFIRMED` and `SESSION_UNAVAILABLE`. Everything else is
`PLAY_REJECTED` on purpose.

Authenticated API routes answer `UNAUTHORIZED` for a bad key, `TOO_MANY_REQUESTS` when a key
exceeds its per-minute allowance, and `INVALID_REQUEST` for a body that does not validate.

## Outbound webhooks

deal-app posts one JSON body per event to the URL configured on the company. This is the
receiver contract: what arrives, how to verify it, and what deliberately never appears.

### Event types

| Type | When |
|---|---|
| `reward.issued` | A play won and a reward was created |
| `reward.claimed` | A winner completed the claim gate |
| `reward.used` | Staff, the dashboard, or the API marked a reward used |
| `reward.use_undone` | A used reward was given back and is spendable again |
| `reward.cancelled` | A reward was ended for good and its budget returned |
| `reward.expired` | An unused reward passed its expiry and released its stock |
| `play.completed` | A play finished, win or lose |
| `campaign.limit_reached` | A campaign hit its lifetime winner cap |
| `campaign.budget_exhausted` | A campaign hit its budget ceiling |
| `quota.warning` | A company crossed its plan warning threshold |

Not every type listed is emitted yet. Treat an unknown `type` as ignorable rather than an
error, so new events never break your receiver.

### Body

```json
{
  "id": "5R8QW2K7NX",
  "type": "reward.issued",
  "occurredAt": "2026-09-02T10:30:00.000Z",
  "companyRef": "cmp_8N2QW5R7XK3VB1D0F4Z6MT",
  "campaignRef": "cam_3K9VB2N8QW5R7XZ1D0F4MT",
  "playRef": "ply_7XK3VB1D0F4Z6MT8N2QW5R",
  "rewardRef": "rwd_1D0F4Z6MT8N2QW5R7XK3VB",
  "playerRef": null,
  "outcome": "win",
  "prizeLabel": "15% off"
}
```

Every field is always present. Optional references are `null` rather than omitted.

`id` is stable per event and is the key to dedupe on. Redelivery is expected: the outbox
retries on any non 2xx and a lapsed delivery lease can re-send a body you already have.

#### References are opaque

`companyRef`, `campaignRef`, `playRef`, `rewardRef` and `playerRef` are opaque, stable,
prefixed identifiers derived from a keyed hash of the underlying record. The same record
always produces the same reference, so you can group and correlate events by them. They are
not database identifiers and they are not reward codes, and they cannot be turned back into
either. To act on a reward, call the authenticated company API; do not attempt to parse a
reference.

#### Never in a body

The reward code, any player email, phone or name, any IP address or hashed IP, the device
cookie, the device fingerprint, prize cost, win rate, prize weights, campaign caps, campaign
counters, internal `_id` values, and the play token. A leaked webhook body is worth nothing on
its own, which is the point. `src/features/webhook/types/webhook.types.spec.ts` enforces this
by construction, and the payload type has no field capable of carrying a code.

**A webhook is the only source of truth for granting an entitlement.** Never grant one from a
`postMessage` on the embed page: a host page can post any message to itself.

### Verifying the signature

Each request carries a `Deal-Signature` header:

```
Deal-Signature: t=1756808400,v1=2f6c...  
```

- `t` is the Unix timestamp, in seconds, of the moment the body was signed.
- `v1` is a hex HMAC-SHA256. There may be **more than one** `v1` value: during a secret
  rotation overlap the same body is signed with every currently valid secret, and you should
  accept the request if any one of them matches.

The signed payload is the timestamp, a literal `.`, then the exact bytes of the request body:

```
signed_payload = "{t}" + "." + raw_request_body
expected       = hex(hmac_sha256(secret, signed_payload))
```

Verify against the **raw** body. Re-serialising the parsed JSON produces different bytes and
will not match. Compare in constant time, and reject a request whose `t` is more than five
minutes from your own clock.

Node example:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secrets) {
  const parts = header.split(',').map(part => part.trim().split('='));
  const timestamp = parts.find(([key]) => key === 't')?.[1];
  const provided = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
  if (!timestamp || provided.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > TOLERANCE_SECONDS) return false;

  const signedPayload = `${timestamp}.${rawBody}`;
  return secrets.some(secret => {
    const expected = createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
    return provided.some(candidate => {
      const left = Buffer.from(expected, 'hex');
      const right = Buffer.from(candidate, 'hex');
      return left.length === right.length && timingSafeEqual(left, right);
    });
  });
}
```

### Delivery behaviour

- Retries follow a bounded backoff schedule and then dead-letter. Respond 2xx quickly and do
  the work asynchronously; the sender times out after 10 seconds.
- Your URL must be `https` on port 443, with no credentials and no IP literal. Hosts that
  resolve to loopback, private, link-local or CGNAT ranges are refused, and the connection is
  pinned to the address that passed that check.
- Redirects are never followed.
- The response body is never stored. Only a status code and a fixed reason code are recorded.


## Before you go live

- Create the secret key and store it server-side only.
- List your allowed sites, so the pop-up key and the iframe are both bound to your domains.
- Set a webhook URL, create a signing secret, and send the test event until it verifies. See
  **Outbound webhooks** above.
- Decide the identity mode. If you need a real one-play-per-person guarantee, it is
  `account_required` or `company_signed`.
- Call `POST /api/v1/rewards/use` from your checkout, with `orderValueCents`, so the sales a game caused are recorded.
