Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

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.

  1. Your app calls identify with the shopper’s opaque customer id, for example cus_7Hq2Lm9x.

  2. The SDK calls your app’s grant source, and your grant source calls your backend inside the shopper’s authenticated session.

  3. Your backend fetches the shopper’s grants from Grona, server to server, and returns them to the app unchanged.

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

  5. At checkout your app reads the SDK’s active treatments and sends each grant token with the order request.

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

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/grants server 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.

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.

Terminal window
npm install --save-exact jose@6.2.12 pg@8.23.1

The samples were tested with these exact versions on Node.js 20, 22, 24 and 26 and on Python 3.13, against PostgreSQL 17.

grona_grant_redemptions.sql
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.

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:

Terminal window
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.

grona-grants.mjs
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:00Z
const MAX_TIME = 4102444800; // 2100-01-01T00:00:00Z
const 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;
}

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.

checkout.mjs
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();
}
}

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

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.

  • SQL. Insert the jti under a unique constraint, as the samples do with INSERT ... 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 plain INSERT that hits the constraint aborts the whole transaction and the order with it, which is why the samples use ON CONFLICT DO NOTHING. Under REPEATABLE READ or SERIALIZABLE isolation, 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 at exp plus your clock leeway, not at exp, or the grant could be used again inside the leeway. EXAT needs Redis 6.2 or later. If the order fails after the key is set, the grant stays spent, which fails closed.

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 typ other than grona-grant+jwt, including the same value in another case, is rejected.
  • alg set to none, and HS256 signed with your public key as the secret, are rejected.
  • An unknown kid, and a known kid with a signature from another key, are rejected.
  • An extra header field such as jku is rejected.
  • An expired grant, an iat in the future, and a lifetime over 24 hours are rejected.
  • Another shopper’s sub, another workspace’s wks, and prv set to true are 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.