# Stripe integration

URL: https://traqio.app/docs/integrations/stripe
Last updated: 2026-11-13

Connect your Stripe account: SaaS payments, renewals and refunds reach the campaign that brought each user, with one server call at sign-up.

**Stripe** bills your SaaS customers and reports each payment with a webhook. Connected to Traqio, your Stripe account brings every paid invoice, renewal and refund onto the user who signed up — so a campaign is credited with the money its sign-ups paid, month one and month twelve, with **one server call at sign-up and none per payment**. It is the web counterpart of the [RevenueCat integration](https://traqio.app/docs/integrations/revenuecat), with the same rules for refunds, test payments and duplicates.

> **Your Stripe, not Traqio's billing** This connects your Stripe account, read-only, to measure your revenue. It has nothing to do with how Traqio bills you.

## Set up

### Report the sign-up once

Where your backend creates the account, report it with the user id and the trackID the visitor arrived with — see [the Node SDK](https://traqio.app/docs/sdks/node) (PHP and Python work the same way). Passing the email is optional: the SDK hashes it on your server and sends only the hash, the fallback key described below.

```ts
await traqio.signup({
  userId: user.id,
  trackId: trackIdFromRequest(req),
  email: user.email, // optional — hashed locally, never sent
});
```

### Put the user id in Stripe metadata

Set `metadata.traqio_user_id` to the **same** user id when you create the Subscription — Stripe copies a subscription's metadata onto every invoice it produces, renewals included. On the Customer works too.

```ts
await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: priceId }],
  metadata: { traqio_user_id: user.id },
});

// Stripe Checkout:
await stripe.checkout.sessions.create({
  mode: "subscription",
  subscription_data: { metadata: { traqio_user_id: user.id } },
  // …
});
```

### Create a restricted key

Stripe Dashboard → Developers → API keys → **Create restricted key**. Set **Customers: Read**, **Invoices: Read** and **Webhook Endpoints: Write**; leave everything else at None. A live key (`rk_live_…`) measures real payments; a test key (`rk_test_…`) lets you try the wiring with test payments only.

### Paste it in Traqio

In the console: your product → Integrations → Revenue → **Stripe**. Traqio reads one customer and one invoice to prove the key, then creates its webhook endpoint in your Stripe account — URL `https://api.traqio.app/webhooks/stripe/<product id>` — and keeps its signing secret. A key Stripe refuses is not saved, and the message names the permission to tick.

> **No Webhook Endpoints permission?** Add the endpoint yourself in Stripe → Developers → Webhooks, with the URL shown in the console and the events listed below, tick « I added the webhook endpoint myself » and paste its signing secret (whsec_…) with the key.

### Make a test-mode payment

With a test key, a test-mode payment shows in the console's **Recent Stripe events** as *Test mode — wiring verified*, with the amount Traqio parsed. It is never counted. Live payments are the ones counted; each line says which user it was credited to, or why it is unmatched.

## How a payment finds its user

Traqio tries these in order, and stops at the first that answers:

| Where | What Traqio reads |
| --- | --- |
| The invoice's subscription | metadata.traqio_user_id — the snapshot Stripe copies onto each invoice |
| The invoice | metadata.traqio_user_id |
| The Stripe Customer | metadata.traqio_user_id, read with the restricted key |
| The customer's email | SHA-256 of the trimmed, lowercased email, matched against the emailHash sent at sign-up — only if exactly one user has it |

A payment that none of them ties to a user is **unmatched**: recorded, counted in the product's revenue, credited to no campaign. A user id Traqio has not seen yet — the payment arrived before the sign-up call — is kept, and the payment joins the user when the sign-up lands.

## What each Stripe event does

| Stripe event | In Traqio |
| --- | --- |
| invoice.paid | a purchase — amount_paid, in the invoice's currency; a €0 invoice (trial, credit balance) moves no money |
| charge.refunded | a refund — negative revenue, its own entry against the invoice it refunds; each partial refund once |
| customer.subscription.created / updated / deleted | logged with the state change (trialing → active…): no money moves |
| Anything in test mode | logged as test mode, never counted |

- **Currency**: each payment is kept in the currency it was paid in, and converted to the product's reporting currency at that day's rate in the reports.
- **Duplicates**: a Stripe event is processed once, and a payment is keyed on its invoice id — a redelivery, or the same invoice also reported by your server with `transactionId`, is one row. See [Counting one purchase once](https://traqio.app/docs/concepts/events-and-revenue#transaction-id).
- **Refunds** are only taken back from payments Traqio recorded: an invoice paid before Stripe was connected was never added, so its refund subtracts nothing.
- **Amounts are gross**: what the customer paid, tax included; the tax share is stored, not applied. Stripe's fees are not subtracted.

## Limits

- Traqio counts **invoices**. A one-off Checkout payment counts when its session creates an invoice (`invoice_creation.enabled`); a bare PaymentIntent with no invoice does not.
- One Stripe account per Traqio product. A Stripe account that sells several products sends all their payments to each connected product, where the others' customers show as unmatched.
- Website payments are attributed and reported in Traqio, but are not sent to Meta or TikTok yet.

## Frequently asked questions

**Why a restricted key and not a Stripe Connect sign-in?**
A restricted key works on any Stripe account the moment it is created, and it is narrower: it reads customers and invoices, and may create one webhook endpoint. It cannot charge, refund or change anything. Traqio refuses a full secret key (sk_…) and stores the restricted key encrypted.

**Does Traqio write anything in my Stripe account?**
One thing: the webhook endpoint it creates for itself, described as Traqio's. Disconnecting Stripe in the console deletes it. Nothing else is written, and no money object is ever touched.

**What if a payment names no user?**
It is still revenue: it is recorded and counted in the product's total, as unmatched — it reaches no campaign. Traqio never gives a payment to a user by guess, and the Stripe events list says why each payment is unmatched.

**Do I still call track() for each payment?**
No. With Stripe connected, signup() is the only server call. If you keep a track() for a Stripe payment, pass the invoice id (in_…) as transactionId with store: "stripe": both reports land on one row.
