Granting Free Credits on Signup or Action
Patterns for signup bonuses, per-action grants, and time-limited trials using deterministic idempotency keys.
Three variations of the same primitive: grant credits with a deterministic idempotency key. Signup bonus keys by user ID. Per-action bonus keys by action ID. Time-limited trial keys by user ID plus an expiry timestamp. Each variation is a single API call.
signup-grant:{user_id} — fires once per useraction-grant:{user_id}:{action_id} — fires once per actionexpires_at to the grant body; idempotency key stays stableGranting Free Credits on Signup or Action
Give users free credits to try your product. Three common variations: signup bonus, per-action bonus, and time-limited trial. Each is a single API call with a deterministic idempotency key.
Variation 1: Signup bonus
Grant credits when a new user creates an account. These credits are permanent fallback — low priority, no expiry.
curl -s -X POST \
-H "X-API-Key: $API_KEY" \
-H "Idempotency-Key: signup-grant:user_42" \
-H "Content-Type: application/json" \
"https://api.quotastack.io/v1/topup/grant" \
-d '{
"external_customer_id": "user_42",
"credits": 3000,
"price_paid": 0,
"currency": "USD",
"priority": 0,
"metadata": {
"source": "signup_grant"
}
}'
Priority 0 means these credits burn last — after any purchased packs or plan credits. No expires_at means they never expire. The user always has a small balance to fall back on.
Idempotency key: signup-grant:{userID} — if your signup flow retries (webhook redelivery, queue replay), the grant executes exactly once. The second call returns the cached response from the first.
Checking if the grant was already given
You don’t need to check. Just call the grant endpoint. If the idempotency key matches a previous request, QuotaStack returns the original response without creating a duplicate block. This is the whole point of deterministic idempotency keys — you can call the endpoint from every code path that might need it (signup handler, migration script, background job) without worrying about double-grants.
Variation 2: Per-action bonus
Grant credits when a user performs a specific action: starting a conversation, making a referral, completing onboarding, etc.
curl -s -X POST \
-H "X-API-Key: $API_KEY" \
-H "Idempotency-Key: conv-start:conv_abc123" \
-H "Content-Type: application/json" \
"https://api.quotastack.io/v1/topup/grant" \
-d '{
"external_customer_id": "user_42:companion_7",
"credits": 50000,
"price_paid": 0,
"currency": "USD",
"priority": 0,
"metadata": {
"source": "conversation_start",
"conversation_id": "conv_abc123"
}
}'
Idempotency key: conv-start:{conversationID} — one grant per conversation, no matter how many times the event fires.
More examples of per-action idempotency keys:
| Action | Idempotency key | Credits |
|---|---|---|
| New conversation | conv-start:{conversationID} | 50,000 mc |
| Referral signup | referral:{referrerID}:{refereeID} | 100,000 mc |
| Complete onboarding | onboarding:{userID} | 20,000 mc |
| Daily login bonus | daily-login:{userID}:{date} | 5,000 mc |
The key structure encodes the uniqueness constraint. A daily login bonus keyed on {userID}:{date} means one grant per user per day — call it on every login and the idempotency layer handles deduplication.
Variation 3: Time-limited trial
Grant credits with an expiry. The user gets N days to try the product for free. Unused trial credits vanish when the trial ends.
curl -s -X POST \
-H "X-API-Key: $API_KEY" \
-H "Idempotency-Key: trial-grant:user_42" \
-H "Content-Type: application/json" \
"https://api.quotastack.io/v1/topup/grant" \
-d '{
"external_customer_id": "user_42",
"credits": 10000,
"price_paid": 0,
"currency": "USD",
"expires_at": "2026-04-20T00:00:00Z",
"priority": 10,
"metadata": {
"source": "trial_grant"
}
}'
Priority 10 so trial credits burn before any purchased credits (priority 0). If the user buys a pack during their trial, the trial credits are consumed first — when the trial expires, only the purchased credits remain.
expires_at is an absolute timestamp. Compute it server-side:
const TRIAL_DAYS = 7;
const expiresAt = new Date(Date.now() + TRIAL_DAYS * 86400000).toISOString();
Combining signup bonus with trial
A common pattern: give users both a permanent small bonus and a larger time-limited trial.
async function onUserSignup(userID: string): Promise<void> {
// Permanent fallback credits (priority 0, no expiry)
await fetch(`${API_BASE}/topup/grant`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `signup-grant:${userID}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: userID,
credits: 3000,
price_paid: 0,
currency: "USD",
priority: 0,
metadata: { source: "signup_grant" },
}),
});
// Time-limited trial credits (priority 10, 7-day expiry)
const trialExpiry = new Date(Date.now() + 7 * 86400000).toISOString();
await fetch(`${API_BASE}/topup/grant`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `trial-grant:${userID}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: userID,
credits: 10000,
price_paid: 0,
currency: "USD",
expires_at: trialExpiry,
priority: 10,
metadata: { source: "trial_grant" },
}),
});
}
Result: two blocks. Trial credits (10,000 mc, priority 10) burn first for 7 days. After the trial expires, the signup bonus (3,000 mc, priority 0) remains as a permanent fallback.
Full code example
const API_BASE = "https://api.quotastack.io/v1";
async function grantCredits(
customerID: string,
credits: number,
idempotencyKey: string,
options: {
priority?: number;
expiresAt?: string;
metadata?: Record<string, string>;
} = {}
): Promise<{ duplicate: boolean }> {
const res = await fetch(`${API_BASE}/topup/grant`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: customerID,
credits,
price_paid: 0,
currency: "USD",
priority: options.priority ?? 0,
expires_at: options.expiresAt ?? undefined,
metadata: options.metadata ?? {},
}),
});
if (!res.ok) {
throw new Error(`Grant failed: ${res.status}`);
}
const body = await res.json();
return { duplicate: body.status === "duplicate" };
}
// Signup bonus
await grantCredits("user_42", 3000, "signup-grant:user_42", {
metadata: { source: "signup_grant" },
});
// Per-action bonus
await grantCredits("user_42:companion_7", 50000, "conv-start:conv_abc123", {
metadata: { source: "conversation_start", conversation_id: "conv_abc123" },
});
// Time-limited trial
const trialExpiry = new Date(Date.now() + 7 * 86400000).toISOString();
await grantCredits("user_42", 10000, "trial-grant:user_42", {
priority: 10,
expiresAt: trialExpiry,
metadata: { source: "trial_grant" },
});
Gotchas
- Use deterministic idempotency keys, not random UUIDs. A random UUID like
idem_a1b2c3d4is unique every time — it defeats the purpose of idempotency. The key must encode the intent:signup-grant:{userID}means “the signup grant for this user.” If you call it twice with the same key, you get the same result. If you call it twice with different random UUIDs, you get two grants. - Don’t set expiry on signup bonuses unless you want them to expire. Priority 0 + no expiry = permanent fallback credits. If you set a 30-day expiry on a signup bonus and the user doesn’t buy anything within 30 days, they lose their free credits and have zero balance. That’s usually not the experience you want.
- Priority 10 on trials is intentional. It ensures trial credits burn before purchased credits. If you grant trial credits at priority 0 (same as wallet), the burn order depends on expiry and creation time, which may not give the behavior you expect.
- The
price_paid: 0field is required. Even though no money changed hands, the field must be present in the request. Set it to 0 for free grants.