Docs / Cookbook / Granting Free Credits on Signup or Action
PROMOTIONAL GRANTS · DETERMINISTIC KEYS

Granting Free Credits on Signup or Action

Patterns for signup bonuses, per-action grants, and time-limited trials using deterministic idempotency keys.

Mental Model

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.

Quick Take
Signup bonus: signup-grant:{user_id} — fires once per user
Per-action bonus: action-grant:{user_id}:{action_id} — fires once per action
Time-limited trial: add expires_at to the grant body; idempotency key stays stable
Check if a grant already fired before re-granting — use the idempotency-key conflict response

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

ActionIdempotency keyCredits
New conversationconv-start:{conversationID}50,000 mc
Referral signupreferral:{referrerID}:{refereeID}100,000 mc
Complete onboardingonboarding:{userID}20,000 mc
Daily login bonusdaily-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_a1b2c3d4 is 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: 0 field is required. Even though no money changed hands, the field must be present in the request. Set it to 0 for free grants.

Concepts used in this recipe

🤖
Building with an AI agent?
Get this page as markdown: /docs/cookbook/free-tier-credits.md · Full index: /llms.txt