Catalog sync
Products and pricing tiers are managed from your code, not the dashboard. You keep a catalog file in your repo and push it with your secret key — so pricing is versioned, reviewable, and the same across environments. The dashboard shows the result read-only.
1. Declare your catalog
Commit a payouts.catalog.json describing each product (by tier) and its regional prices. unit_amount is in minor units (paise/cents). The optional app block sets your homepage and pricing links (shown on your product page).
{
"app": {
"homepage_url": "https://blogs.elixpo.com",
"pricing_url": "https://blogs.elixpo.com/pricing"
},
"products": [
{
"tier": "member",
"name": "Blogs Member",
"description": "Member-only reads, higher limits…",
"prices": [
// One-time payment — buyer manually re-purchases each cycle.
{ "nickname": "India", "currency": "INR", "unit_amount": 19900, "interval": "month", "region": "IN", "type": "one_time" },
// Autopay (recurring) — Razorpay charges automatically each cycle.
// The buyer is redirected to Razorpay's hosted mandate page on
// first checkout; subsequent renewals are silent.
{ "nickname": "India · Autopay", "currency": "INR", "unit_amount": 19900, "interval": "month", "region": "IN", "type": "recurring" },
{ "nickname": "Global", "currency": "USD", "unit_amount": 600, "interval": "month", "type": "one_time" }
]
}
]
}2. Push it with your secret key
POST https://payouts.elixpo.com/v1/sync
Authorization: Bearer <ELIXPO_PAY_API_KEY>
Content-Type: application/json
<the contents of payouts.catalog.json>A tiny script makes it a one-liner you can run in CI or by hand:
// scripts/sync-catalog.mjs
import { readFile } from "node:fs/promises";
const catalog = JSON.parse(await readFile("payouts.catalog.json", "utf8"));
const res = await fetch("https://payouts.elixpo.com/v1/sync", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ELIXPO_PAY_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ products: catalog.products }),
});
// /v1/sync returns HTTP 200 even when a product is rejected — you MUST
// inspect the body. Trusting the status code alone silently no-ops the sync.
const out = await res.json();
if (!res.ok || out.ok === false || (out.errors?.length ?? 0) > 0) {
console.error("catalog sync failed:", JSON.stringify(out.errors ?? out));
process.exit(1);
}
console.log(`synced ${out.synced?.length ?? 0} products`);How sync reconciles
- Each product upserts by
(app, tier)— same tier updates in place. - Prices reconcile by
(currency, region, interval)— matching prices update, new ones are added. - An active price that's no longer in the file is deactivated (never hard-deleted, so history stays intact).
- Changing a
recurringprice'sunit_amountorintervalre-mints the Razorpay plan automatically (provider plans are immutable). New checkouts use the new amount; subscriptions already active keep billing their original amount until they renew or cancel. - Send a single product as the bare object, or many under
products.
Check the response
/v1/sync returns HTTP 200 even when a product fails validation — always inspect the body, and treat ok: false or a non-empty errors array as a failure. Trusting the status code alone silently no-ops the sync.
// Success
{ "ok": true, "app": "blogs", "synced": [ { "product": { "tier": "member" }, "prices": [ … ] } ], "errors": [] }
// Rejected — still HTTP 200, but ok:false with per-product errors
{ "ok": false, "synced": [], "errors": [
{ "tier": null, "error": "invalid_tier", "error_description": "product.tier required (a-z 0-9 _)" }
] }Read it back
Render your pricing page from the live catalog — public, no secret needed:
GET https://payouts.elixpo.com/v1/catalog?app=<your-client-id>