---
name: dealcraft
description: Put a DealCraft promotion game on a website and take its reward codes at checkout. Use when a task mentions DealCraft, dealcraft.io, a spin-to-win/scratch-card/mystery-box promotion, a `pk_live_`/`sk_live_` DealCraft key, `w.js`, or redeeming a DealCraft reward code.
---

# Integrating DealCraft

DealCraft attaches a chance-based game (wheel, scratch card, mystery box) to a shop. The server
decides every outcome before any animation runs, against a win rate and hard spending caps the
shop set. A winner walks away with a reward code the shop's checkout can take.

Canonical copy of this file: `https://dealcraft.io/agents/dealcraft/SKILL.md`
Full contract: `https://dealcraft.io/agents/dealcraft/API.md`

## Install this guide

- **Claude Code** — `mkdir -p .claude/skills/dealcraft && curl -fsSL https://dealcraft.io/agents/dealcraft/SKILL.md -o .claude/skills/dealcraft/SKILL.md`
- **Codex / Cursor / any agent that reads AGENTS.md** — `curl -fsSL https://dealcraft.io/agents/dealcraft/SKILL.md >> AGENTS.md`
- **Anything else** — fetch the URL and put it wherever that tool keeps project instructions.

## Before writing any code, settle three things

1. **Which keys exist.** The shop creates them on the DealCraft dashboard under *Integrations*.
   A `pk_live_…` public key belongs in a page. An `sk_live_…` / `sk_test_…` secret key belongs on
   a server and nowhere else. If the task has no keys yet, stop and ask for them — do not invent
   placeholders that ship.
2. **Which delivery mode the shop wants.** Ask if it is not stated.
3. **Whether the checkout has to take reward codes.** If yes, that is a separate server-side
   integration; read *Take a code at checkout* below and do it properly.

| Mode | For | What you write |
|---|---|---|
| Public link or QR | A shop with no site | Nothing. The dashboard issues the link |
| Website pop-up | A shop with a site | One script tag |
| Inline iframe | The game sitting inside a page | One iframe |
| API | A team with its own backend and accounts | Two HTTP calls |

## Website pop-up

### Plain HTML, Shopify, Wix, WordPress

Paste once, before the closing `</body>` tag (Shopify: `theme.liquid`; Wix: Settings → Custom
code → body end; WordPress: a header-and-footer scripts plugin).

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

### Next.js (App Router)

**A plain `<script>` tag written inside a React 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. This is the single most common broken install.

```tsx
// src/app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://dealcraft.io/w.js"
          data-key={process.env.NEXT_PUBLIC_DEALCRAFT_PUBLIC_KEY}
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}
```

### React SPA / Vue / Svelte

Append the element once, on mount, and remove it on unmount:

```ts
const script = document.createElement('script');
script.async = true;
script.src = 'https://dealcraft.io/w.js';
script.dataset.key = PUBLIC_KEY;
document.body.appendChild(script);
```

### Opening it from your own button

```ts
window.dealApp?.open();
```

### Rules the loader obeys

Delay, scroll depth, exit intent, audience share, shows per visitor per day, and path prefixes are
all set by the shop on the game's *Website pop-up* settings. Do not reimplement them in the host
page.

### Checking the install

The pop-up obeys "once per visitor per day" from the moment it is live, so the first look is
otherwise also the last for 24 hours. Add `?deal_preview=1` to any page: the loader ignores every
visitor rule and opens at once without recording a show. Only a request carrying that flag behaves
this way.

If nothing appears, the loader says why in the browser console — unknown key, pop-up off, or an
origin that is not on the allowed list. It never shows a visitor an error.

## Inline iframe

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

Framing is authorised per request against the shop's allowed-site list. A page that is not on the
list gets a 404, not a broken frame. Add the site to the list first; no redeploy is involved.

The frame posts `deal:ready`, `deal:resize` (with `height`), `deal:play_started`, `deal:result`,
`deal:claim_required`, `deal:claimed` and `deal:error` to the exact origin it was framed from.
Resize the frame from `deal:resize`:

```js
window.addEventListener('message', event => {
  if (event.origin !== 'https://play.dealcraft.io') 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. No
message carries a reward code. Entitlements come from a webhook or the authenticated API.

## Public link

Every game can expose one permanent public link — the shop turns it on under *Settings → Public
link* and gets `https://play.dealcraft.io/<locale>/l/<token>` plus a QR code. Every visit through
it starts a new play. Print it, message it, or open it on a tablet at the counter. *Refresh*
issues a new token and kills the old link.

## Take a code at checkout

Two calls, in this order, **from your server only**. Both routes reject any request carrying an
`Origin` header, so a key in front-end JavaScript is a leaked key.

**Build against a test key first.** `sk_test_…` reads real codes and answers what *would* have
happened, with `"simulated": true`, without spending anything. Swap the key for the live one when
you ship; nothing else changes.

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

```ts
const response = await fetch(`https://dealcraft.io/api/v1/rewards/${encodeURIComponent(code)}`, {
  headers: { Authorization: `Bearer ${process.env.DEALCRAFT_SECRET_KEY}` },
});
const reward = await response.json();
```

```json
{
  "code": "8PEP-2QJJ-STGA",
  "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`. `percent` carries `percentOff` (a whole percent: `10` means 10% off). `fixed`
carries `amountOffCents` (minor units). `free_item`, `custom` and `consolation` carry neither —
read `label` and `sku` and decide yourself. `sku` is whatever the shop typed on the prize,
verbatim; DealCraft never interprets it, so it is the field that lets a free-item prize mean
something to your catalogue.

`usable: false` means do not discount the cart. `reason` is `already_used`, `expired`,
`awaiting_claim` or `void`. An unknown code answers 404 `REWARD_NOT_FOUND`.

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

```ts
await fetch('https://dealcraft.io/api/v1/rewards/use', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.DEALCRAFT_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ code, orderValueCents: 4200, requestId: `order-${orderId}-1` }),
});
```

**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.

`requestId` makes your own retry safe: the same id gets the reward back instead of a refusal, while
a second till still gets `REWARD_NOT_USABLE`. Send anything unique to that attempt.

When an order is cancelled, `POST /api/v1/rewards/undo-use` puts the code back and returns the
spend to reserved. `POST /api/v1/rewards/cancel` ends the reward for good and returns its budget.
They are different calls; pick by what actually happened. An expired reward can be neither.

## Webhooks

A webhook is the only source of truth for granting an entitlement. Each request carries

```
Deal-Signature: t=<unix seconds>,v1=<hex hmac sha256>
```

The signed payload is `"{t}" + "." + <raw request body>`. Verify against the **raw** body — a
re-serialised JSON body produces different bytes and will not match. There may be more than one
`v1` during a secret rotation; accept the request if any one matches. Compare in constant time and
reject a timestamp more than five minutes from your own clock.

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

const TOLERANCE_SECONDS = 300;

export function verifyDealcraft(rawBody: string, header: string, secrets: string[]): boolean {
  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;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false;

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

Dedupe on `id`; redelivery is expected. Treat an unknown `type` as ignorable so new events never
break your receiver. Respond 2xx within ten seconds and do the work asynchronously.

A body never contains a reward code, a player's email, phone or name, an IP, a device id, a prize
cost, a win rate, or campaign caps. `companyRef`, `campaignRef`, `playRef`, `rewardRef` and
`playerRef` are opaque and stable — correlate on them, never parse them.

## Identity, if a per-person cap has to be real

| Mode | Who plays | Is one-play-per-person enforceable |
|---|---|---|
| `anonymous_claim` | Anyone | **No.** Counted per browser; clearing cookies earns another play |
| `account_required` | Signed-in DealCraft players | Yes, per player account |
| `company_signed` | Users your backend vouches for | Yes, per reference you sign |

For `company_signed`, mint a five-minute token **server side** and hand it to the page:

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

## Mistakes that actually happen

- A plain `<script>` tag inside a React component. Use the framework's script loader.
- A `pk_test_` key in a page. There is no rehearsal for a play; the pop-up and the embed need a
  live public key. Test with a real game whose budget is set low.
- A secret key in front-end code or in a `NEXT_PUBLIC_*` variable. The API refuses any request
  carrying an `Origin` header, but the key is still burned — rotate it.
- Marking a code used at the cart instead of after capture.
- Granting an entitlement from a `postMessage`.
- Verifying a webhook against the parsed body instead of the raw bytes.
- Embedding before adding the site to *Integrations → Allowed sites*; the frame answers 404.

## Done checklist

- [ ] Public key in the page, secret key server-side only, both from environment variables
- [ ] Allowed sites listed, so the pop-up key and the iframe are bound to real domains
- [ ] `?deal_preview=1` opens the pop-up on the real site
- [ ] Checkout calls `rewards/{code}` at the cart and `rewards/use` after capture, with
      `orderValueCents` and a `requestId`
- [ ] Webhook receiver verifies the signature against the raw body and dedupes on `id`
- [ ] Identity mode matches the promise being made about per-person limits
