Documentation

Zevron for apps: setup guide

Last updated

Connect web billing, send your app’s events from your server, and check that web chargebacks, App Store refund requests and failed renewals are covered. About an afternoon, most of it on your backend. Code for Swift, Kotlin, React Native and the web.

1.How the pieces fit

Four things talk to Zevron. Your web billing (Stripe) tells us about chargebacks and failed renewals. Your server tells us what customers did in the app. Apple tells us when a customer asks for a refund on an In-App Purchase. And you tell us, once, what your customers agreed to.

  1. Create a workspace at zevron.ai/start and connect Stripe (we read disputes and payments, and submit the evidence you approve).
  2. Create a usage key. Keep it on your server.
  3. Add one route to your backend that forwards events (the relay below).
  4. Call it from the app and the website: session_start, feature_used and purchase first.
  5. Optional: connect the App Store and choose how consent works.
  6. Open the Apps page in Zevron and watch the checklist turn green.

This is packaging of things Zevron already does for any subscription business: the usage API, Compelling Evidence 3.0, App Store refund answers and failed-payment recovery. Nothing here needs a special SDK.

2.Connect web billing

In Zevron, open Settings, Connections, and connect Stripe with one click or with a restricted key. We read disputes, charges, customers and invoices, and upload evidence and submit the responses you approve; we never move money or change a price.

Paddle and RevenueCat Web Billing are in early access. Tell us when you sign up and we work your web chargebacks from the dispute emails you forward until they open. If you use RevenueCat to manage subscriptions, you can also connect it for subscriber history next to Stripe.

3.Create a key and keep it on your server

In Settings, Connections, Your product (usage API), choose Create key. It starts zv_live_ and is shown once. Store it as a server secret, ZEVRON_KEY.

Never ship the key inside the app. A binary can be unpacked in minutes, and anyone with the key can write events into your account, including events that would help a fraudulent dispute. The app sends events to your own backend, which authenticates the user, adds the customer and IP, and forwards them. That is the relay below.

The full reference for keys, batches, retries and limits is the usage API page. Up to 500 events per request, and an event with an id is stored once however many times you send it.

4.What to send

Send a line for what a customer does, not everything they do. A bank and Apple both ask the same questions: did they agree, did they use it, was it them. These events answer them. The first three are enough to start.

EventSend it whenFieldsWhy
session_startStart hereThe app or the web product opens, once per session.email, user_id, ip, device_idShows the customer came back after the charge. Logins and sessions are the proof most disputes turn on.
loginSomeone signs in.email, user_id, ip, device_idSame as a session, and the cleanest sign that the account owner is the cardholder.
feature_usedStart hereA lesson, a workout, a match, a prompt: the thing they pay for.email, user_id, device_id, properties.featureBanks and Apple both ask whether the product was used. This answers with dates and counts.
purchaseStart hereA subscription starts or renews on the web, or an In-App Purchase completes.email, user_id, ip, device_id, properties.payment_intent (web) or properties.transaction_id (App Store), properties.app_account_tokenTies the buyer's IP and device to the exact charge, which is what Compelling Evidence 3.0 matches against.
terms_acceptedThe customer agrees to the price and renewal at your paywall or checkout.email, user_id, ip, device_id, properties.terms_version, properties.disclosure_text, properties.checkboxThe first argument in every renewal response: they agreed, and saw the terms.
consumption_consentThe customer accepts your terms for sharing usage data with Apple.user_id or properties.app_account_tokenApple's refund process requires the customer's consent. Without a record, we send nothing.
consumption_consent_withdrawnThey withdraw that consent.same as consumption_consentA withdrawal always wins. We stop sharing at once.

Every event can carry properties.platform (ios, android or web) so you can tell where it came from in your own reports.

5.Match events to customers

A web chargeback names a card and an email. An Apple refund request names a transaction and, if you set one, an appAccountToken. Your events need to carry whichever the dispute will name.

ForWe match onWhich is
A web disputeemail on the eventThe email on the Stripe payment. Use the address the customer gave at web checkout.
A web dispute (fallback)properties.payment_intentThe Stripe payment itself. Send it in your purchase event to tie the buyer's IP and device to that charge.
An App Store refund requestproperties.app_account_token, or user_id set to itThe appAccountToken you passed to StoreKit when the purchase was made. Apple sends it back with the request.
Both, for one customeremail and the same user_id on every eventUse one stable ID per account as the appAccountToken (a UUID you keep per user). Then a customer who bought on the web and on iPhone is one person.

The simplest pattern: give every account a UUID when it is created, use it as the appAccountToken on every StoreKit purchase, and send it as user_id (and in properties.app_account_token) on every event, together with the account’s email. The relay below does exactly that from your session.

On iOS the token is set at purchase time: product.purchase(options: [.appAccountToken(accountId)]). Apple sends it back on every transaction and refund request for that purchase. A purchase made without one cannot be matched by token, only by transaction ID, so set it before you ship.

6.The relay and the client code

Start with the server relay, then add the client for each place the customer uses your product. Tabs marked as running in the app post to your backend. Only the server tabs mention the key.

Runs on your serverThe one place the key lives. It checks the event name, takes the customer from your session, and adds the IP.

// Your backend (Express). The ONLY place the Zevron key lives: set ZEVRON_KEY as a server secret.
import express from "express";
import net from "node:net";
import rateLimit from "express-rate-limit";
const app = express();
app.use(express.json({ limit: "20kb" }));

// Only these names go through, so a client cannot make up its own.
const ALLOWED = new Set(["session_start", "login", "feature_used", "purchase", "terms_accepted", "consumption_consent", "consumption_consent_withdrawn"]);
// Only these properties go through, and only as short strings. A client must not be able to name a Stripe charge
// (payment_intent, charge_id...) or set the account token: those come from your webhook and your session.
const PROPS = new Set(["feature", "terms_version", "disclosure_text", "checkbox", "consented", "transaction_id", "original_transaction_id"]);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Per signed-in customer, so one account cannot flood your Zevron events.
const limit = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, keyGenerator: (req) => String(req.user?.id ?? req.ip) });

app.post("/analytics/zevron", requireSession, limit, async (req, res) => {
  const { id, event, at, device_id, properties } = req.body ?? {};
  if (typeof event !== "string" || !ALLOWED.has(event)) return res.status(400).json({ error: "unknown event" });
  if (typeof id !== "string" || id.length > 100) return res.status(400).json({ error: "id must be a string" });
  if (typeof device_id !== "string" || device_id.length > 100) return res.status(400).json({ error: "device_id must be a string" });
  // A time in the future, or one that is not a time, is dropped: the server's clock is used instead.
  const when = Date.parse(at);
  const time = Number.isFinite(when) && when <= Date.now() + 60_000 ? new Date(when).toISOString() : new Date().toISOString();
  const props = {};
  for (const [k, v] of Object.entries(properties ?? {})) if (PROPS.has(k) && typeof v === "string") props[k] = v.slice(0, 2000);

  // The customer comes from YOUR session, never from the request body.
  // The IP is the phone's or browser's address as your server saw it. Trust X-Forwarded-For only when your own proxy sets it
  // (in Express: app.set("trust proxy", 1)), then req.ip is the right value.
  const ip = req.ip && net.isIP(req.ip) ? req.ip : undefined;

  const r = await fetch("https://zevron.ai/api/v1/events", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.ZEVRON_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      id: `${req.user.id}:${id}`,          // retries are ignored, not duplicated; namespaced so one customer cannot collide with another
      event,
      at: time,
      email: req.user.email,                // matches a web dispute
      user_id: req.user.appAccountToken,    // the UUID you pass to StoreKit; matches an App Store request
      ip,                                   // an empty or invalid address would reject the event, so it is left out
      device_id,
      properties: { ...props, app_account_token: req.user.appAccountToken, platform: req.get("x-app-platform") === "ios" || req.get("x-app-platform") === "android" ? req.get("x-app-platform") : "web" },
    }),
  }).catch(() => null);
  res.status(r?.ok ? 204 : 502).end();      // never let Zevron being down break your app
});

Why the relay? Because your server is the only place that can say who the customer is (from your session) and which IP the request came from. Neither should be taken from the body of a request the client wrote.

7.App Store refund requests

When a customer asks Apple for a refund on an In-App Purchase, Apple sends a refund-request notification and waits 12 hours for consumption information. Zevron answers from your events, if the customer agreed.

  1. In App Store Connect, create an App Store Server API key (Users and Access, Integrations, In-App Purchase). Note the Issuer ID, the Key ID and your bundle ID, and download the .p8 file once.
  2. In Zevron, Settings, Connections, App Store: paste them. Copy the notification URL shown there into App Store Connect as your Production Server URL, so Apple tells us when a request opens.
  3. Choose how consent works in Approvals and automation.

There are three settings for consent. Each customer’s own record is the default and the safe one:

  • Each customer’s own record. You send consumption_consent when the customer accepts your data terms. We answer only for those customers.
  • Your terms cover everyone. You confirm that your terms obtain every customer’s consent. A customer who has withdrawn is still left out.
  • Send nothing. We never share usage with Apple.

Record a customer’s agreement from the app, through your relay:

// When the customer accepts your data terms (a screen or a toggle in the app), record it. Apple's refund
// process needs this before any usage can be shared. Withdraw with the same call and event "consumption_consent_withdrawn".
track("consumption_consent", { terms_version: "2026-09", consented: "true" });

We send Apple only what its form takes: whether consent exists, how long the account was active, how much was used, and a refund preference taken from the usage. Apple takes no files and no written argument, and it decides. We cannot guarantee a refund is declined.

8.Check it works

Open the Apps page in Zevron. It has three things for this:

  • Watch events arrive. The newest events from your app, updating while the page is open, with the IP and device of each. If your relay works you will see your own session within seconds.
  • Send a test event. One clearly marked event written by the page itself, so you can see how an event looks. It does not count toward the checklist, and no dispute reads it.
  • Setup health. A checklist read from the last 30 days of real traffic: web billing connected, a key created, events arriving, IP and device on at least 80% of them, events matching customers, the App Store key valid, consent set, and recovery on. Each line that is not green has the fix beside it.

From a terminal you can send one with curl. This is for your own testing; it uses your key, so do not paste it anywhere shared:

curl -X POST https://zevron.ai/api/v1/events \
  -H "Authorization: Bearer $ZEVRON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "test_1",
    "email": "[email protected]",
    "user_id": "6f1c2a9e-3b0d-4e55-9a7f-2d8c1b4e7a10",
    "event": "session_start",
    "ip": "203.0.113.7",
    "device_id": "d_91",
    "properties": { "app_account_token": "6f1c2a9e-3b0d-4e55-9a7f-2d8c1b4e7a10", "platform": "ios" }
  }'

Your app is live when web billing, events, IP and device, customer matching and recovery are green, and real events with an IP and a device have arrived on seven different days of the last 30. The App Store lines are optional and only apply if you have iOS subscribers.

9.If something looks wrong

What you seeWhat to check
Events return 401The key was stopped, mistyped or is for another workspace. Make a new one in Settings, Connections, Your product, and update the secret on your server.
Events arrive without an IPYour relay is reading its own address. Take the first entry of X-Forwarded-For (or your platform's equivalent) from the request the app made to you, and leave ip out when it is empty.
The IP and device lines stay amberUnder 80% of the last 30 days' events carry both. The usual cause is a client that calls Zevron directly, or a background job that has no request to take them from.
A web dispute shows no in-app logsEvents are matched by the email on the payment. Check the events carry the same email the customer used at web checkout, and that the purchase event names the payment_intent.
An App Store request went unansweredEither there was no record that the customer agreed to share usage, or the key failed Apple's check. The setup page shows which; Apple's 12 hours cannot be reopened.
The checklist is green but the app is not counted as liveZevron counts an app as live once web billing is connected and real events with both an IP and a device have arrived on seven different days of the last 30. The events line on your setup page shows how many days you are at.
Test events do not turn anything greenBy design. They prove the page and the path work. The checklist reads events from your server.

10.What we keep and what we do not

We read an event only when a dispute or a refund request opens for that customer, and only the ones from the 60 days before it. Nothing is sold or shared, and a customer’s events are removed when they ask us to erase their data. We never see a card number.

On the Apple links: As of April 2026, apps in the United States can link customers to an outside payment page instead of selling only through Apple's in-app purchase. Apple charges a fee on those linked purchases, and a court has found that fee too high. Which fee is allowed is still being fought: Apple has said it plans to ask the Supreme Court to review the case. TechCrunch, April 6, 2026: Apple and Epic Games, the Supreme Court appeal and the App Store commission. Reviewed October 2026. This is a summary, not legal advice. The rules differ outside the United States and are changing. Check Apple's current guidelines and talk to your lawyer before you change how you sell.

Setup guide for apps · Zevron