Integra DealCraft en tu código

Un solo archivo le enseña a un agente de programación toda la integración: dónde va el juego, qué clave va en una página y cuál nunca, y cómo aceptar un código de premio en el pago sin quemarlo en un carrito abandonado.

Instálala

La skill es un único archivo markdown servido desde este dominio. Descárgalo en lo que sea que lea tu agente y tendrá el contrato en lugar de adivinarlo.

  • Claude Code

    La coloca en la carpeta de skills del proyecto, donde se carga en cualquier tarea de DealCraft.

  • Codex

    La añade a AGENTS.md, que Codex ya lee en cada ejecución.

  • Cualquier otro agente

    Descárgala y ponla donde esa herramienta guarde las instrucciones del proyecto.

Esta referencia es el propio contrato de integración, el mismo archivo que lee tu agente de programación. Ejemplos de código en cURL, Node y PHP.

Integrating DealCraft

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 DealCraft posts to you — are covered in Outbound webhooks below.

Quickstart: reward codes at your checkout in four steps

Everything below is the long form. If you only want the game on your site and its codes taken at your checkout, this is the whole job:

  1. Create a secret key. Dashboard → Integrations → Create key. The secret starts with sk_live_ (or sk_test_ for a key that rehearses and never spends) and is shown once. Store it on your server as DEALCRAFT_SECRET_KEY. It must never reach a browser: every authenticated route refuses a request that carries an Origin header.
  2. Put the game on your site. Paste the pop-up loader with your pk_live_ public key (see Website pop-up), or frame the embed (see Inline iframe). Open any page with ?deal_preview=1 to see it at once.
  3. Check the code at the cart, from your server, when the customer types it: Discount the cart only when usable is true. Read kind, then percentOff or amountOffCents. This call spends nothing.
  4. Mark it used after the payment is captured, with your order id as requestId:

That is a complete integration. Webhooks, identity and reconciliation are for when you need them.

AnswerMeaningWhat your checkout does
200 with usable: trueThe code is goodApply the discount
200 with usable: falseUsed, expired, cancelled, or awaiting the winner's contactRefuse the code; reason says which
404 REWARD_NOT_FOUNDTypo, or another shop's codeRefuse the code
409 REWARD_NOT_USABLE on useSomeone else spent it firstCharge without the discount, or cancel the order
401 UNAUTHORIZEDWrong, revoked or browser-side keyFix the key; never retry in a loop
cURL
curl https://dealcraft.io/api/v1/rewards/8PEP-2QJJ-STGA \  -H "Authorization: Bearer $DEALCRAFT_SECRET_KEY"
cURL
curl -X POST https://dealcraft.io/api/v1/rewards/use \  -H "Authorization: Bearer $DEALCRAFT_SECRET_KEY" \  -H "Content-Type: application/json" \  -d '{"code":"8PEP-2QJJ-STGA","orderValueCents":4200,"requestId":"order-1001"}'

Pick a delivery mode

ModeWho it is forWhat you write
Hosted link or QRA shop with no developerNothing. Every published game has a permanent link and QR
Website pop-upA shop with a websiteOne <script> tag
Inline iframeA team that wants the game inside a pageOne <iframe> tag
APIA team with a backend and its own accountsTwo HTTP calls

Website pop-up

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

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, week or month, 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:

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 week", so the first look is otherwise also the last one for days. 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 show 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.

Width. The pop-up is 380px wide by default. Add data-width to the tag to change it, between 320 and 640: data-width="480" shows the glass card at its full width. On a phone it always takes the screen width. The WooCommerce plugin has the same as Pop-up width in its settings.

Straight to the cart. Add data-claim with an address on your own site and a win shows one button, Claim my prize, instead of a code to copy. The loader sends the winner to that address with code=<the code> added, and your page puts the prize in the cart. An address on another site is ignored. The WooCommerce plugin sets it by itself: the product on screen goes in the cart, the code is applied (a free item with it) and the cart opens; a prize whose minimum purchase the cart does not reach yet is kept and applied as soon as it does.

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.

How often, and to whom. The shop sets it on the game under Sharing → My website and can change it at any time without touching the site: times per visitor per day, week or month (counted over the last 24 hours, 7 days or 30 days, not reset at midnight), the share of visitors who ever see it (each browser is drawn once and keeps its draw), the delay, the scroll depth, exit intent and the paths. Do not rebuild any of this in the host page. The counts live in the visitor's browser, so a cleared browser or a second device starts them over; a real per-person limit is the game's play limit and identity mode (Identity below).

When. A new game starts with its pop-up on and a 3-second delay, unless another game of the shop already has the pop-up on. A game is offered only between its start and end (a day and a time each, in the shop's time zone, set on the game's Settings tab); outside them the loader shows nothing.

Want your own modal instead? Open the embed in an overlay of your own. The embed posts deal:resize for the height and deal:result when the player sees the outcome, so your overlay can size itself and offer your discount field after a win.

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.

HTML
<script async src="https://dealcraft.io/w.js" data-key="pk_live_..."></script>
React
import Script from 'next/script';<Script src="https://dealcraft.io/w.js" data-key="pk_live_..." strategy="afterInteractive" />

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

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

Messages the frame posts to your page

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

TypeWhenExtra fields
deal:readyThe frame has mounted
deal:resizeThe content height changedheight
deal:play_startedThe player triggered a play
deal:resultThe result is on screenoutcome, prizeLabel
deal:claim_requiredThe win needs a verified contact first
deal:claimedThe player finished the claim gate
deal:errorThe play was refused or failed

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.

Node
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`;});

The API

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

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:

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 pathPurpose
POST /api/v1/tokensMint a short-lived identity token for one of your own users
GET /api/v1/campaignsList your configurations
POST /api/v1/sessionsCreate a timed session from a configuration
GET /api/v1/sessionsList sessions
GET /api/v1/sessions/{id}/statsCustomers, 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/useMark a reward used, after the order is paid
POST /api/v1/rewards/undo-useGive a code back when the order was cancelled
POST /api/v1/rewards/cancelEnd a reward for good and return its budget
GET /api/v1/rewardsPage 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.

HTTP
Authorization: Bearer sk_live_<key id>_<secret>
Response
{ "simulated": true, "intent": "use", "code": "8PEP-...", "wouldSucceed": true, "reason": null }

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:

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.

label names the reward in each language the shop wrote the game in (ka, en, es); a language the game is not written in is an empty string, or absent. Show the customer's language when it is filled and any filled one otherwise. Webhook events carry one prizeLabel: the English name when the game has one, else the first language the reward is named in. deal:result names it in the language the player is reading.

Every reward also carries sku: whatever the shop typed into Your product code on the prize, verbatim. DealCraft 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.

minPurchaseCents is the purchase the reward comes with: the rest of the order, not counting the reward itself, must come to at least this many minor units of currency before you apply it. null means no condition. A free adapter with minPurchaseCents: 10000 is free only beside 100 GEL of other goods; an adapter alone in the cart is not discounted. DealCraft cannot see your cart, so your checkout enforces it — usable does not change with it.

usable: false means do not discount the cart. reason says why: already_used, expired, awaiting_claim (the winner has not confirmed a contact yet), cancelled, or repeat_winner.

wonBefore is same_browser, same_network or null: whether the play that issued this code came from a browser or a network that had already won in this game, within the game's own rules. A win the rules allowed is a fresh win: under "one play a day" a win tomorrow is good; under "once a month" a second win within 30 days is a repeat; after as many wins as the game's plays per player, the next is a repeat; a game with no play limit never has one. A repeat refuses the code as repeat_winner, and POST /api/v1/rewards/use answers REWARD_NOT_USABLE for it. The first win always stays good. A shared connection can flag an honest customer, so the business owner can still hand the code over from their own dashboard; the API and the staff screen cannot. 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:

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, and GET /api/v1/analytics/campaigns/{id} sums it as orderValueCents beside rewardsUsed.

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.

cURL
curl https://dealcraft.io/api/v1/rewards/8PEP-2QJJ-STGA \  -H "Authorization: Bearer sk_live_..."
Response
{  "code": "8PEP-2QJJ-STGA",  "kind": "percent",  "sku": null,  "minPurchaseCents": null,  "percentOff": 10,  "amountOffCents": null,  "currency": "GEL",  "label": { "en": "10% off", "ka": "10% ფასდაკლება", "es": "" },  "status": "claimed",  "usable": true,  "reason": null,  "wonBefore": null,  "expiresAt": "2026-09-13T09:00:00.000Z"}
cURL
curl -X POST https://dealcraft.io/api/v1/rewards/use \  -H "Authorization: Bearer sk_live_..." \  -H "Content-Type: application/json" \  -d '{"code":"8PEP-2QJJ-STGA","orderValueCents":4200}'

Turning a percent into a discount your payment provider honours

DealCraft decides who won and what they won. It never touches money, holds a card, or knows what currency your cart is in. Applying the discount is your payment system's job, and the shape is the same whichever one you use:

  1. The browser sends only the code. Never what it is worth.
  2. Your server asks GET /api/v1/rewards/{code} and learns it is 40% off.
  3. Your server applies that through its own provider.
  4. After the payment is captured, POST /api/v1/rewards/use.

Step 3 is the only line that changes between providers:

ProviderWhat step 3 is
Dodo PaymentsCreate a discount with usage_limit: 1, restricted to the product, then pass its code as discount_code on the checkout session
ShopifyA discount code through the Admin API, or a cart-level Function
WooCommerceNothing: install the DealCraft for WooCommerce plugin (below)
Your own cartRecalculate the total server-side

Fixed prices are not a problem. With Dodo, for example, the discount is minted per win rather than picked from a list you maintained in advance:

If the discount cannot be applied, refuse the checkout. Do not fall through and charge full price on a cart the customer was told was discounted. That is a chargeback and a support ticket, and it is the failure mode this whole path exists to prevent.

Node
const reward = await dealcraft.rewards.get(code);          // your server, never the browserconst discount = await dodo.discounts.create({  amount: reward.percentOff * 100,                          // 4000 = 40%  type: 'percentage',  usage_limit: 1,                                           // cannot be reused  restricted_to: [productId],});await dodo.checkoutSessions.create({ ...cart, discount_code: discount.code });

WooCommerce: the plugin does all four steps

DealCraft for WooCommerce takes codes in the shop's normal coupon field, with no code to write.

  1. Download it from the dashboard (Integrations → WooCommerce) or directly from https://dealcraft.io/api/v1/connectors/woocommerce/dealcraft-woocommerce.zip.
  2. WordPress → Plugins → Add New → Upload Plugin → Activate.
  3. Create keys in the same dashboard panel, then paste the secret key and the public key into WooCommerce → Settings → DealCraft. The page says at once whether the key works.
  4. Open the shop with ?deal_preview=1, win, and take the code through the checkout.

With WP-CLI the whole install is:

GET /api/v1/connectors/woocommerce answers the newest version and its download link; the plugin reads it to offer updates under Dashboard → Updates, so a fix reaches every shop without a reinstall.

  • A code with DealCraft's shape is checked with GET /api/v1/rewards/{code} and applied as an in-memory coupon. Nothing is created in the shop's coupon table, and the shop's own coupons are never sent to DealCraft.
  • The code is checked again, uncached, when the order is placed. A code that cannot be checked is refused; a cart is never discounted on a guess.
  • A reward with a minimum purchase is refused until the rest of the cart, not counting the reward, reaches it.
  • A code already on another order that is still Pending payment or On hold (an instalment waiting for the bank, say) is refused, so one code cannot ride two orders before the first is paid. When that order is cancelled or fails, the code works again.
  • When the order reaches Processing (or Completed, the shop's choice, for cash on delivery) the plugin calls use with requestId wc-<order id>-<code>, so its own retries are safe. A failed call is retried every five minutes, six times, and noted on the order.
  • Cancelled, Refunded or Failed calls undo-use.

How each prize reaches the cart:

PrizeIn WooCommerce
percentPercentage off the cart
fixedAmount off the cart, amountOffCents / 100 in the shop's currency
free_itemOne unit free of the product whose WooCommerce SKU equals the prize's product code; entering the code adds that product to the cart if it is not there yet
custom with product code free_shippingFree delivery, for shipping methods set to require a coupon
any other custom, consolationRefused at the cart with "contact the shop"; hand it over from the dashboard

A sk_test_ key works end to end and spends nothing: each order gets a note saying what would have happened.

cURL
wp plugin install https://dealcraft.io/api/v1/connectors/woocommerce/dealcraft-woocommerce.zip --activatewp option update dealcraft_secret_key 'sk_live_...'wp option update dealcraft_public_key 'pk_live_...'

Percent travels, fixed does not

amountOffCents is in minor units of currency, the ISO 4217 code of the game the reward was won in ("GEL", "USD", "EUR"). A fixed reward of 500 with "currency": "GEL" is five lari off, not five of whatever your checkout is denominated in. Compare `currency` with your order's before you apply a `fixed` reward, and refuse or convert it when they differ: some providers convert automatically, so the same prize can be 500 of a unit worth three times what the shop intended.

percent rewards have no such problem: 40% off is 40% off in every currency, before or after any conversion. If you sell internationally, prefer percent prizes. Reserve fixed for a shop that prices in exactly one currency, and check kind before you trust either field.

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:

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.

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

When an order is cancelled

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

What happenedCallEffect on the codeEffect on your budget
Order cancelled or refunded, customer got nothingPOST /api/v1/rewards/undo-useSpendable again, back to the state it held beforeSpend returns to reserved
The reward itself should end — fraud, a withdrawn promotionPOST /api/v1/rewards/cancelDead for goodReturned to the budget

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.

cURL
curl -X POST https://dealcraft.io/api/v1/rewards/undo-use \  -H "Authorization: Bearer sk_live_..." \  -H "Content-Type: application/json" \  -d '{"code":"8PEP-2QJJ-STGA"}'

Reconciling against your own orders

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.

cURL
curl "https://dealcraft.io/api/v1/rewards?status=used,cancelled&limit=200" \  -H "Authorization: Bearer sk_live_..."

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.

ModeWho playsIs "one play per person" enforceable
anonymous_claimAnyoneNo. The count is per browser. Clearing cookies earns another play
company_signedUsers your backend vouches forYes, 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.

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

The hosted surfaces (the permanent link, the QR code, the pop-up and the embed) cannot carry this token, so a campaign in company_signed mode refuses every visitor on them with COMPANY_IDENTITY_REQUIRED. The mode is for a page you build yourself.

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.

Node
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());
Node
await fetch('/api/v1/session', {  method: 'POST',  headers: { 'Content-Type': 'application/json' },  body: JSON.stringify({    companySlug: 'your-company',    campaignSlug: 'spring',    identityToken: minted.identityToken,  }),});

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. At the plan limit 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, 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

DealCraft 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

TypeWhen
reward.issuedA play won and a reward was created
reward.claimedA winner completed the claim gate
reward.usedStaff, the dashboard, or the API marked a reward used
reward.use_undoneA used reward was given back and is spendable again
reward.cancelledA reward was ended for good and its budget returned
reward.expiredAn unused reward passed its expiry and released its stock
play.completedA play finished, win or lose
campaign.limit_reachedA campaign hit its lifetime winner cap
campaign.budget_exhaustedA campaign hit its budget ceiling
quota.warningA 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

Every field is always present. Optional references are null rather than omitted. playerRef is always null: DealCraft has no player accounts, and the field stays so the body's shape never changes.

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.

Response
{  "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"}

Verifying the signature

Each request carries a Deal-Signature header:

  • 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:

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.

In Node and PHP:

HTTP
Deal-Signature: t=1756808400,v1=2f6c...
HTTP
signed_payload = "{t}" + "." + raw_request_bodyexpected       = hex(hmac_sha256(secret, signed_payload))
Node
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 company_signed.
  • Call POST /api/v1/rewards/use from your checkout, with orderValueCents, so the sales a game caused are recorded.