Grants and order verification
Your backend has two jobs for offers that carry money. It fetches grants for the signed-in shopper so the app can show the offer, and it verifies those grants again when the shopper places an order, because the app cannot be trusted with money. This page gives the procedure and complete samples in Node.js and Python. If grants are new to you, read Offers and grants first.
How a grant reaches an order
Section titled “How a grant reaches an order”-
Your app calls
identifywith the shopper’s opaque customer id, for examplecus_7Hq2Lm9x. -
The SDK calls your app’s grant source, and your grant source calls your backend inside the shopper’s authenticated session.
-
Your backend fetches the shopper’s grants from Grona, server to server, and returns them to the app unchanged.
-
The SDK verifies each grant. It shows an offer, for example a weekend offer of 10% off the order, only when a grant matches it.
-
At checkout your app reads the SDK’s active treatments and sends each grant token with the order request.
-
Your order backend verifies every token, re-checks the campaign’s conditions against the order it priced, records each grant as used inside the order transaction, and applies the treatment from the verified claims.
Serve the grant source
Section titled “Serve the grant source”The SDK calls your app’s grant source when the shopper is identified and again shortly before the nearest grant expires. Your app passes the call to an endpoint on your backend, and that endpoint does four things:
- It authenticates the request with the shopper’s session and takes the customer id from the session, never from the request. Otherwise anyone could read another shopper’s offers.
- It calls
POST https://api.grona.ai/v1/mobile/grantsserver to server with your workspace secret key. The Mobile grants API page describes the request and the response, and Authentication describes the key. - It returns the list of compact tokens to the app as Grona sent them.
- It answers quickly. The SDK waits at most 10 seconds for the grant source, treats a timeout or a failure as no answer, and tries again later with backoff.
The grants endpoint is available on request, like the mobile SDKs.
What your order backend checks
Section titled “What your order backend checks”Run every check on every token. A failure means no treatment for that grant, and the order still succeeds.
| Check | Rule |
|---|---|
| Size | The token is at most 4,096 characters |
| Header | Exactly alg, kid and typ, with alg equal to ES256 and typ equal to grona-grant+jwt. This rejects none, HS256 and every other algorithm |
| Key | kid names a grant key you pinned. Unknown key ids are rejected |
| Signature | ES256 with that key |
| Customer | sub equals the customer of the authenticated order. A guest order gets no treatment |
| Workspace | wks equals your workspace key, for example acme-market |
| Preview | prv is false |
| Ids and value | cmp, var, trt and jti match [A-Za-z0-9._:-]{1,128}, and val is an integer of 0 or more |
| Time | iat and exp are Unix seconds between 2020 and 2100, iat is not in the future, exp has not passed, and exp is after iat and at most 24 hours later |
| Basket | Your rules for the campaign hold for the order your server priced, for example a minimum basket total. Use your own totals, never totals the app sent |
| Single use | No earlier order has redeemed this jti |
Then apply the treatment from the verified claims only: cmp, var, trt and val. The value, demo flag and placement that the app reports with a treatment, and the treatments in the app’s purchase event, are display data, and your backend ignores them.
The samples allow 30 seconds of clock leeway on iat and exp. Grants carry no nbf claim, but both libraries reject a token whose nbf is in the future.
Install the libraries
Section titled “Install the libraries”npm install --save-exact jose@6.2.12 pg@8.23.1pip install PyJWT==2.15.1 cryptography==50.0.2 "psycopg[binary]==3.3.6"The samples were tested with these exact versions on Node.js 20, 22, 24 and 26 and on Python 3.13, against PostgreSQL 17.
Create the redemption table
Section titled “Create the redemption table”CREATE TABLE grona_grant_redemptions ( jti text PRIMARY KEY, order_id text NOT NULL, customer_id text NOT NULL, campaign_id text NOT NULL, variant_id text NOT NULL, treatment_type text NOT NULL, value_milli bigint NOT NULL, expires_at timestamptz NOT NULL, redeemed_at timestamptz NOT NULL DEFAULT now());The primary key on jti makes single use atomic. Keep the rows, because they are also your record for revenue reporting. If you prune them, delete only rows whose expires_at has passed by more than your clock leeway, since verification rejects those grants anyway.
Verify and redeem a grant
Section titled “Verify and redeem a grant”Give your server the grant public keys from Grona as JSON in the GRONA_GRANT_KEYS environment variable, keyed by kid. Pin the current key and the next one:
GRONA_GRANT_KEYS='{"acme-market-grants-2026-10":"<base64 SPKI from Grona>","acme-market-grants-2027-04":"<base64 SPKI from Grona>"}'Each value is a P-256 public key as base64 DER SubjectPublicKeyInfo, the same format your app pins. The module refuses to load a key that is not P-256.
import { createPublicKey } from 'node:crypto';import { jwtVerify } from 'jose';
const WORKSPACE = 'acme-market';const GRANT_TYP = 'grona-grant+jwt';const LEEWAY_SECONDS = 30;const MIN_TIME = 1577836800; // 2020-01-01T00:00:00Zconst MAX_TIME = 4102444800; // 2100-01-01T00:00:00Zconst MAX_LIFETIME_SECONDS = 24 * 60 * 60;const MAX_TOKEN_LENGTH = 4096;const ID = /^[A-Za-z0-9._:-]{1,128}$/;
// Grant public keys from Grona, pinned by kid: {"<kid>": "<base64 DER SubjectPublicKeyInfo>"}.const GRANT_KEYS = new Map( Object.entries(JSON.parse(process.env.GRONA_GRANT_KEYS)).map(([kid, spki]) => { const key = createPublicKey({ key: Buffer.from(spki, 'base64'), format: 'der', type: 'spki' }); if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails.namedCurve !== 'prime256v1') { throw new Error(`Grona grant key ${kid} is not a P-256 public key`); } return [kid, key]; }),);
export class GrantRejected extends Error {}
const reject = (reason) => { throw new GrantRejected(reason);};const isId = (value) => typeof value === 'string' && ID.test(value);const isTime = (value) => Number.isSafeInteger(value) && value >= MIN_TIME && value <= MAX_TIME;
// Returns the verified claims or throws GrantRejected. customerId is the customer of the authenticated order.export async function verifyGrant(token, customerId) { if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH) reject('token size'); let claims; try { ({ payload: claims } = await jwtVerify( token, (header) => { const fields = Object.keys(header).sort().join(','); if (fields !== 'alg,kid,typ' || header.alg !== 'ES256' || header.typ !== GRANT_TYP) reject('header'); const key = typeof header.kid === 'string' ? GRANT_KEYS.get(header.kid) : undefined; return key ?? reject('unknown kid'); }, { algorithms: ['ES256'], clockTolerance: LEEWAY_SECONDS }, )); } catch (err) { if (err instanceof GrantRejected) throw err; reject(`jws: ${err.code ?? err.message}`); } const now = Math.floor(Date.now() / 1000); if (!isId(customerId) || claims.sub !== customerId) reject('sub'); if (claims.wks !== WORKSPACE) reject('wks'); if (claims.prv !== false) reject('prv'); for (const name of ['cmp', 'var', 'trt', 'jti']) if (!isId(claims[name])) reject(name); if (!Number.isSafeInteger(claims.val) || claims.val < 0) reject('val'); if (!isTime(claims.iat) || !isTime(claims.exp)) reject('iat or exp'); if (claims.iat > now + LEEWAY_SECONDS) reject('iat in the future'); if (claims.exp <= now - LEEWAY_SECONDS) reject('expired'); if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_LIFETIME_SECONDS) reject('lifetime'); return claims;}
// Records the grant as used by this order, inside the order transaction. False means another order used it.export async function redeemGrant(tx, claims, orderId) { const result = await tx.query( `INSERT INTO grona_grant_redemptions (jti, order_id, customer_id, campaign_id, variant_id, treatment_type, value_milli, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, to_timestamp($8)) ON CONFLICT (jti) DO NOTHING`, [claims.jti, orderId, claims.sub, claims.cmp, claims.var, claims.trt, claims.val, claims.exp], ); return result.rowCount === 1;}import base64import jsonimport osimport reimport time
import jwtfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives.serialization import load_der_public_key
WORKSPACE = "acme-market"GRANT_TYP = "grona-grant+jwt"LEEWAY_SECONDS = 30MIN_TIME = 1577836800 # 2020-01-01T00:00:00ZMAX_TIME = 4102444800 # 2100-01-01T00:00:00ZMAX_LIFETIME_SECONDS = 24 * 60 * 60MAX_TOKEN_LENGTH = 4096ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
def _load_keys(config: str) -> dict: keys = {} for kid, spki in json.loads(config).items(): key = load_der_public_key(base64.b64decode(spki, validate=True)) if not isinstance(key, ec.EllipticCurvePublicKey) or key.curve.name != "secp256r1": raise ValueError(f"Grona grant key {kid} is not a P-256 public key") keys[kid] = key return keys
# Grant public keys from Grona, pinned by kid: {"<kid>": "<base64 DER SubjectPublicKeyInfo>"}.GRANT_KEYS = _load_keys(os.environ["GRONA_GRANT_KEYS"])
class GrantRejected(Exception): pass
def _is_id(value) -> bool: return isinstance(value, str) and ID.fullmatch(value) is not None
def _is_time(value) -> bool: return type(value) is int and MIN_TIME <= value <= MAX_TIME
def verify_grant(token: str, customer_id: str) -> dict: """Return the verified claims or raise GrantRejected. customer_id is the customer of the authenticated order.""" if not isinstance(token, str) or len(token) > MAX_TOKEN_LENGTH: raise GrantRejected("token size") try: header = jwt.get_unverified_header(token) except jwt.PyJWTError as err: raise GrantRejected(f"header: {err}") from None if set(header) != {"alg", "kid", "typ"} or header["alg"] != "ES256" or header["typ"] != GRANT_TYP: raise GrantRejected("header") key = GRANT_KEYS.get(header["kid"]) if isinstance(header["kid"], str) else None if key is None: raise GrantRejected("unknown kid") try: claims = jwt.decode(token, key, algorithms=["ES256"], leeway=LEEWAY_SECONDS) except jwt.PyJWTError as err: raise GrantRejected(f"jws: {err}") from None now = int(time.time()) if not _is_id(customer_id) or claims.get("sub") != customer_id: raise GrantRejected("sub") if claims.get("wks") != WORKSPACE: raise GrantRejected("wks") if claims.get("prv") is not False: raise GrantRejected("prv") for name in ("cmp", "var", "trt", "jti"): if not _is_id(claims.get(name)): raise GrantRejected(name) value = claims.get("val") if type(value) is not int or value < 0: raise GrantRejected("val") iat, exp = claims.get("iat"), claims.get("exp") if not (_is_time(iat) and _is_time(exp)): raise GrantRejected("iat or exp") if iat > now + LEEWAY_SECONDS: raise GrantRejected("iat in the future") if exp <= now - LEEWAY_SECONDS: raise GrantRejected("expired") if exp <= iat or exp - iat > MAX_LIFETIME_SECONDS: raise GrantRejected("lifetime") return claims
def redeem_grant(conn, claims: dict, order_id: str) -> bool: """Record the grant as used by this order, inside the order transaction. False means another order used it.""" cur = conn.execute( """ INSERT INTO grona_grant_redemptions (jti, order_id, customer_id, campaign_id, variant_id, treatment_type, value_milli, expires_at) VALUES (%s, %s, %s, %s, %s, %s, %s, to_timestamp(%s)) ON CONFLICT (jti) DO NOTHING """, (claims["jti"], order_id, claims["sub"], claims["cmp"], claims["var"], claims["trt"], claims["val"], claims["exp"]), ) return cur.rowcount == 1Call it from your order handler
Section titled “Call it from your order handler”Run the checks inside the transaction that writes the order. If the order fails and rolls back, the redemption rolls back with it, so the shopper can use the grant when they try again.
import { GrantRejected, redeemGrant, verifyGrant } from './grona-grants.mjs';
// Your own rules for each Grona campaign, checked against the order as your server priced it.const CAMPAIGNS = new Map([['weekend-offer', { treatment: 'percent_off', valueMilli: 10000, minTotalMinor: 4000 }]]);
function qualifies(claims, order) { const campaign = CAMPAIGNS.get(claims.cmp); return ( campaign !== undefined && claims.trt === campaign.treatment && claims.val === campaign.valueMilli && order.totalMinor >= campaign.minTotalMinor );}
// pool is your pg.Pool. customerId comes from the authenticated session, never from the request body.// grantTokens are the tokens the app sent with the order. Returns the claims of each treatment to apply.export async function placeOrder(pool, customerId, order, grantTokens) { const tokens = Array.isArray(grantTokens) ? grantTokens.slice(0, 50) : []; const applied = []; const tx = await pool.connect(); try { await tx.query('BEGIN'); await tx.query('INSERT INTO orders (id, customer_id, total_minor, currency) VALUES ($1, $2, $3, $4)', [ order.id, customerId, order.totalMinor, order.currency, ]); for (const token of tokens) { let claims; try { claims = await verifyGrant(token, customerId); } catch (err) { if (!(err instanceof GrantRejected)) throw err; console.warn(`Grona grant not applied: ${err.message}`); continue; } if (applied.some((done) => done.cmp === claims.cmp) || !qualifies(claims, order)) continue; if (!(await redeemGrant(tx, claims, order.id))) continue; // already used by another order applied.push(claims); } // Apply each treatment here, inside the transaction, from claims.trt and claims.val only. await tx.query('COMMIT'); return applied; } catch (err) { await tx.query('ROLLBACK'); throw err; } finally { tx.release(); }}import logging
from grona_grants import GrantRejected, redeem_grant, verify_grant
log = logging.getLogger(__name__)
# Your own rules for each Grona campaign, checked against the order as your server priced it.CAMPAIGNS = {"weekend-offer": {"treatment": "percent_off", "value_milli": 10000, "min_total_minor": 4000}}
def qualifies(claims: dict, order: dict) -> bool: campaign = CAMPAIGNS.get(claims["cmp"]) return ( campaign is not None and claims["trt"] == campaign["treatment"] and claims["val"] == campaign["value_milli"] and order["total_minor"] >= campaign["min_total_minor"] )
def place_order(conn, customer_id: str, order: dict, grant_tokens: list) -> list: """conn is your psycopg connection. customer_id comes from the authenticated session, never from the request body. grant_tokens are the tokens the app sent with the order. Returns the claims of each treatment to apply.""" tokens = grant_tokens[:50] if isinstance(grant_tokens, list) else [] applied = [] with conn.transaction(): conn.execute( "INSERT INTO orders (id, customer_id, total_minor, currency) VALUES (%s, %s, %s, %s)", (order["id"], customer_id, order["total_minor"], order["currency"]), ) for token in tokens: try: claims = verify_grant(token, customer_id) except GrantRejected as err: log.warning("Grona grant not applied: %s", err) continue if any(done["cmp"] == claims["cmp"] for done in applied) or not qualifies(claims, order): continue if not redeem_grant(conn, claims, order["id"]): continue # already used by another order applied.append(claims) # Apply each treatment here, inside the transaction, from claims["trt"] and claims["val"] only. return appliedThe handler applies a grant only when its treatment type and value match the campaign you expect, so a grant with any other value is not applied. It applies at most one grant per campaign to an order, and a second grant for the same campaign stays unspent. The SDK holds at most 50 grants, so the handler looks at no more than 50 tokens. Replace the INSERT INTO orders statement with your own order write, and keep the CAMPAIGNS rules in step with the campaigns you run.
Apply the treatment from trt and val. For percent_off, val is in thousandths of a percentage point, so the discount is the order total in minor units times val, divided by 100,000, rounded down and capped at the order total. For this example’s 10000, a 52.00 order (5200) gets 5.20 (520) off.
In Python, conn.transaction() begins a transaction and commits it at the end of the block when the connection is idle. On a connection that is already inside a transaction, it becomes a savepoint instead, and nothing commits until your code commits the outer transaction.
Rejections are logged with a reason, never with the token. Count them by reason: a rise in signature or key failures is worth investigating.
Use each grant once
Section titled “Use each grant once”Key single use on jti, never on the token string
Section titled “Key single use on jti, never on the token string”ECDSA signatures are malleable. For any valid signature (r, s), the pair (r, n - s) is also valid, where n is the order of the P-256 curve, and anyone holding a token can compute it without the private key. So one grant has at least two valid token strings. Some JWT libraries also accept base64url padding on the signature segment, which gives the same grant yet another string. A backend that remembered used token strings would apply the same grant more than once. Keying on jti covers every form.
Make the insert atomic
Section titled “Make the insert atomic”- SQL. Insert the
jtiunder a unique constraint, as the samples do withINSERT ... ON CONFLICT (jti) DO NOTHING, and treat zero inserted rows as already used. Two orders racing with one grant get exactly one treatment. In PostgreSQL a plainINSERTthat hits the constraint aborts the whole transaction and the order with it, which is why the samples useON CONFLICT DO NOTHING. UnderREPEATABLE READorSERIALIZABLEisolation, retry the order on a serialization failure, as for any write conflict. - Redis. Run
SET grona:grant:<jti> <order id> NX EXAT <exp + leeway>before you commit the discount, and treat a nil reply as already used. Expire the key atexpplus your clock leeway, not atexp, or the grant could be used again inside the leeway.EXATneeds Redis 6.2 or later. If the order fails after the key is set, the grant stays spent, which fails closed.
Test your verifier
Section titled “Test your verifier”Before you go live, run your verifier against these cases. The samples on this page pass all of them.
- A valid grant is accepted, and treated once.
- The same grant on a second order: the order succeeds with no treatment.
- The same grant with its signature changed to
(r, n - s)passes verification, then fails single use. - Two concurrent orders with one grant: exactly one is treated.
- A payload changed after signing is rejected.
- A
typother thangrona-grant+jwt, including the same value in another case, is rejected. algset tonone, andHS256signed with your public key as the secret, are rejected.- An unknown
kid, and a knownkidwith a signature from another key, are rejected. - An extra header field such as
jkuis rejected. - An expired grant, an
iatin the future, and a lifetime over 24 hours are rejected. - Another shopper’s
sub, another workspace’swks, andprvset totrueare rejected. - An order below the campaign’s minimum gets no treatment and leaves the grant unspent.
- A grant whose value differs from the campaign’s value gets no treatment and stays unspent.