Olympus Docs
CookbookIntegrations & billing

Link Olympus identity to Stripe customer

Keep your billing system in sync with auth

When a user signs up in your app, you typically want a Stripe customer record too. This is a recipe for that linkage.

Pattern

User signs up → Kratos identity created (UUID-A)
              → Stripe customer created (cus_B)
              → Athena identity has `metadata.stripe_customer_id = cus_B`

Each side knows about the other.

Post-registration hook

# kratos.yml
selfservice:
  flows:
    registration:
      after:
        password:
          hooks:
            - hook: web_hook
              config:
                url: http://your-backend/internal/create-stripe-customer
                response:
                  ignore: true  # fire-and-forget; don't block signup if fails

Your handler:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET);

export async function POST(req: Request) {
  const { identity } = await req.json();
  
  const customer = await stripe.customers.create({
    email: identity.traits.email,
    name: `${identity.traits.first_name} ${identity.traits.last_name}`,
    metadata: { olympus_identity_id: identity.id },
  });
  
  // Patch identity with Stripe ID
  await fetch(`${KRATOS_ADMIN}/admin/identities/${identity.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify([
      { op: "add", path: "/metadata_admin/stripe_customer_id", value: customer.id }
    ]),
  });
  
  return Response.json({ ok: true });
}

In your app:

const identity = await kratos.toSession(cookie);
const stripeCustomerId = identity.identity.metadata_admin?.stripe_customer_id;

const subscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId });

Updating

If user changes email, propagate to Stripe:

selfservice:
  flows:
    settings:
      after:
        profile:
          hooks:
            - hook: web_hook
              config:
                url: http://your-backend/internal/sync-stripe
const { identity } = await req.json();
const stripeId = identity.metadata_admin?.stripe_customer_id;
if (stripeId) {
  await stripe.customers.update(stripeId, { email: identity.traits.email });
}

On account deletion

When user deletes account:

// pre-deletion hook
const { identity_id } = await req.json();
const identity = await kratos.adminGetIdentity(identity_id);
const stripeId = identity.metadata_admin?.stripe_customer_id;

if (stripeId) {
  // Cancel subscriptions
  const subs = await stripe.subscriptions.list({ customer: stripeId });
  for (const sub of subs.data) {
    await stripe.subscriptions.cancel(sub.id);
  }
  // Delete customer (or keep for tax record)
  // await stripe.customers.del(stripeId);  // careful: deletes payment methods
}

Webhook from Stripe

When Stripe events happen (subscription created, payment failed), update Olympus side:

// stripe-webhook.ts
export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");
  const body = await req.text();
  const event = stripe.webhooks.constructEvent(body, sig!, WEBHOOK_SECRET);
  
  if (event.type === "customer.subscription.deleted") {
    const sub = event.data.object as Stripe.Subscription;
    const customerId = sub.customer as string;
    const customer = await stripe.customers.retrieve(customerId);
    const olympusId = (customer as Stripe.Customer).metadata.olympus_identity_id;
    
    // Update identity in Olympus
    await fetch(`${KRATOS_ADMIN}/admin/identities/${olympusId}`, {
      method: "PATCH",
      body: JSON.stringify([
        { op: "replace", path: "/metadata_public/plan", value: "free" }
      ]),
    });
    
    // Or use Stripe customer.metadata as source of truth, see below
  }
  
  return Response.json({ received: true });
}

Source-of-truth decision

Decide: which system holds the canonical plan?

Option A: Olympus identity holds plan

identity.metadata_public.plan = "pro".

Pro: easy to gate features (if identity.plan === 'pro'). Con: must sync from Stripe every time plan changes.

Option B: Stripe customer holds plan

Query Stripe at decision time.

Pro: always current. Con: latency + cost per check.

Option C: Hybrid

Cache plan in identity, refresh hourly + on Stripe webhook events.

Most apps: Option C.

Feature gating

async function canAccess(identityId: string, feature: string) {
  const identity = await kratos.getIdentity(identityId);
  const plan = identity.metadata_public?.plan ?? "free";
  const limits = {
    free: ["basic_feature"],
    pro: ["basic_feature", "premium_feature"],
  };
  return limits[plan]?.includes(feature) ?? false;
}

Or use scopes (more OAuth2-native): the access token carries the plan via custom claim.

Trial logic

When user starts a trial:

const customer = await stripe.customers.update(stripeId, {
  metadata: { trial_started: new Date().toISOString() }
});

In your app, calculate trial expiration:

const trialStart = customer.metadata.trial_started;
const trialEnded = new Date(trialStart).getTime() < Date.now() - 14 * 24 * 3600 * 1000;

Soft block features post-trial.

Common pitfalls

Two customers per user

Hook fires twice (Kratos retry). You get two Stripe customers, second one orphan.

Fix: idempotency. Check before creating.

const existing = await stripe.customers.search({
  query: `metadata['olympus_identity_id']:'${identity.id}'`,
});
if (existing.data.length > 0) return existing.data[0];
return stripe.customers.create(...);

Stripe customer without Olympus identity

User created in Stripe directly (admin tool, import). No matching Kratos identity → orphan.

Periodic reconciliation:

const customers = await stripe.customers.list({ limit: 100 });
for (const c of customers.data) {
  if (!c.metadata.olympus_identity_id) {
    // Find or create Olympus identity by email
  }
}

Sandbox / test customers

Stripe has test mode (sk_test_...). Olympus dev / staging should use test mode. Don't mix.

Use env-aware key:

const stripe = new Stripe(
  process.env.NODE_ENV === "production" 
    ? process.env.STRIPE_SECRET_LIVE 
    : process.env.STRIPE_SECRET_TEST
);

On this page