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:
- Create a secret key. Dashboard → Integrations → Create key. The secret starts with
sk_live_(orsk_test_for a key that rehearses and never spends) and is shown once. Store it on your server asDEALCRAFT_SECRET_KEY. It must never reach a browser: every authenticated route refuses a request that carries anOriginheader. - 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=1to see it at once. - Check the code at the cart, from your server, when the customer types it: Discount the cart only when
usableistrue. Readkind, thenpercentOfforamountOffCents. This call spends nothing. - 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.
| Answer | Meaning | What your checkout does |
|---|---|---|
200 with usable: true | The code is good | Apply the discount |
200 with usable: false | Used, expired, cancelled, or awaiting the winner's contact | Refuse the code; reason says which |
404 REWARD_NOT_FOUND | Typo, or another shop's code | Refuse the code |
409 REWARD_NOT_USABLE on use | Someone else spent it first | Charge without the discount, or cancel the order |
401 UNAUTHORIZED | Wrong, revoked or browser-side key | Fix the key; never retry in a loop |
curl https://dealcraft.io/api/v1/rewards/8PEP-2QJJ-STGA \ -H "Authorization: Bearer $DEALCRAFT_SECRET_KEY"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
| Mode | Who it is for | What you write |
|---|---|---|
| Hosted link or QR | A shop with no developer | Nothing. Every published game has a permanent link and QR |
| 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
Every published game has one permanent link, /c/<company>/<game>, with its QR code under Sharing → A link or QR. Every visit starts a new play and the link never expires. For a timed run at an event, create an event from the game instead: it has its own /play/<token> link and its own analytics, and shows a designed expired state when it ends.
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.
<script async src="https://dealcraft.io/w.js" data-key="pk_live_..."></script>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.
<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.
| 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 |
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.
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 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.
Authorization: Bearer sk_live_<key id>_<secret>{ "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 https://dealcraft.io/api/v1/rewards/8PEP-2QJJ-STGA \ -H "Authorization: Bearer sk_live_..."{ "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 -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:
- The browser sends only the code. Never what it is worth.
- Your server asks
GET /api/v1/rewards/{code}and learns it is 40% off. - Your server applies that through its own provider.
- After the payment is captured,
POST /api/v1/rewards/use.
Step 3 is the only line that changes between providers:
| Provider | What step 3 is |
|---|---|
| Dodo Payments | Create a discount with usage_limit: 1, restricted to the product, then pass its code as discount_code on the checkout session |
| Shopify | A discount code through the Admin API, or a cart-level Function |
| WooCommerce | Nothing: install the DealCraft for WooCommerce plugin (below) |
| Your own cart | Recalculate 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.
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.
- Download it from the dashboard (Integrations → WooCommerce) or directly from
https://dealcraft.io/api/v1/connectors/woocommerce/dealcraft-woocommerce.zip. - WordPress → Plugins → Add New → Upload Plugin → Activate.
- 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.
- 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
usewithrequestIdwc-<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:
| Prize | In WooCommerce |
|---|---|
percent | Percentage off the cart |
fixed | Amount off the cart, amountOffCents / 100 in the shop's currency |
free_item | One 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_shipping | Free delivery, for shipping methods set to require a coupon |
any other custom, consolation | Refused 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.
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.
-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 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 |
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 -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 "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.
| Mode | Who plays | Is "one play per person" enforceable |
|---|---|---|
anonymous_claim | Anyone | No. The count is per browser. Clearing cookies earns another play |
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.
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.
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());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
| 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
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.
{ "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:
tis the Unix timestamp, in seconds, of the moment the body was signed.v1is a hex HMAC-SHA256. There may be more than onev1value: 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:
Deal-Signature: t=1756808400,v1=2f6c...signed_payload = "{t}" + "." + raw_request_bodyexpected = hex(hmac_sha256(secret, signed_payload))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
httpson 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/usefrom your checkout, withorderValueCents, so the sales a game caused are recorded.