Building integrations
This is the engineer's guide to connecting a billing system, in-house database, or any other source of subscription revenue to GrowPanel.
It walks you through the data model, the three endpoints you'll write to, the schemas, the two sync patterns (cron and webhook), where to host the pipeline, and skeleton code in JavaScript and Python you can copy from. End-to-end — one read.
When to build a custom integration
Use one of our native connectors (Stripe, Chargebee, Recurly, Google Sheets) if your billing is fully on one of those. They're plug-and-play and require zero code.
Build a custom integration when:
- You're on a billing system we don't natively support.
- Your billing data lives in your own database or product (usage records, internal subscriptions, etc.) and you want it in GrowPanel.
- You're on a platform that uses Stripe under the hood but generates one-off invoices instead of real Stripe subscriptions, and you'd rather model the subscription state yourself than rely on heuristics.
- You want to combine multiple sources — e.g. a Stripe connector for most customers, plus a custom feed for an internal product where billing happens elsewhere.
Custom integrations run alongside native connectors — they don't replace them. You can have a Stripe data source AND a Custom API data source on the same account.
How GrowPanel calculates metrics (60-second overview)
GrowPanel works with three data types:
| Type | What it represents |
|---|---|
| Customers | Who is paying you |
| Plans | What they're paying for (pricing, billing interval) |
| Invoices | Records of what they actually paid |
From these three inputs, GrowPanel derives all the analytics: MRR, ARR, churn, expansion, contraction, reactivation, cohort retention, LTV, ARPA.
The key insight: invoices drive everything. GrowPanel doesn't track "subscriptions" as a separate concept — it reconstructs subscription state from the invoice history. Send an invoice for a customer who had MRR last month at a higher amount this month, and it's automatically classified as expansion. Send no invoice for the expected billing period, and it's churn. You don't tell GrowPanel which movement type applies — the diff against the customer's previous state does that automatically.
A customer paying $1,200/year on an annual plan contributes $100/month MRR (the amount, normalized to monthly via the plan's billing_freq).
Setup
Step 1: Create the data source
- In GrowPanel, navigate to Settings → Data sources.
- Click Add data source → select Custom API.
- Name it (e.g. "Internal billing platform").
- Click Save.
The dialog shows an access_token once — this is your API key. Copy it now; the full value is never shown again. (You can generate a new key later under Settings → API Keys, but that's a key rotation, not retrieval.)
Step 2: Note the data source ID
Each customer, plan and invoice you push must include the data source ID it belongs to. Open the data source from the list — the ID is shown in the URL and on the detail page. Save it as an environment variable in your sync code.
Step 3: Authenticate
All endpoints live under https://api.growpanel.io. Authenticate with a Bearer header:
Authorization: Bearer YOUR_API_KEY
The three endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/data/customers | POST | Create or update customers (bulk-friendly) |
/data/plans | POST | Create or update plans (bulk-friendly) |
/data/invoices | POST | Create or update invoices (bulk-friendly) |
All three accept either a single object or a JSON array. They upsert by your external id. Each response is a per-row result array so partial failures are visible without rolling back the whole batch.
Data reference
Customer object
{
"id": "cust_123",
"name": "Acme Inc",
"email": "[email protected]",
"created_date": "2024-01-15T00:00:00Z",
"country": "us",
"state": "ca",
"trial_started": "2024-01-10T00:00:00Z",
"custom_variables": {
"industry": "Technology",
"company_size": "50-100"
},
"data_source": "ds_abc123"
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Your stable customer ID |
name | string | Conditional | Name or email is required |
email | string | Conditional | Name or email is required |
created_date | ISO 8601 | Yes | When the customer first appeared |
country | string | Yes | ISO 3166 alpha-2 code, lowercase (us, de, dk) |
state | string | No | US/CA state code, lowercase |
trial_started | ISO 8601 | No | Sets status to trialing until the first paid invoice |
custom_variables | object | No | String-keyed map; surfaces as filterable variables |
data_source | string | Yes | Data source ID |
Plan object
{
"id": "plan_pro_monthly",
"name": "Pro Monthly",
"billing_freq": "month",
"billing_freq_count": 1,
"currency": "usd",
"data_source": "ds_abc123"
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Your stable plan ID. Stored as external_id. |
name | string | Yes | Display name |
billing_freq | string | Yes | day, week, month or year |
billing_freq_count | integer | No | Defaults to 1. Use 3 + month for quarterly, 12 + month for an annual-modeled-monthly plan. |
currency | string | Yes | ISO 4217 three-letter code, lowercase |
data_source | string | Yes | Data source ID |
billing_freq + billing_freq_count is how GrowPanel normalizes revenue to monthly. A $1,200 annual plan (billing_freq: "year") and an equivalent modeled as billing_freq: "month", billing_freq_count: 12 produce the same MRR.
Invoice object
{
"id": "inv_456",
"customer_id": "cust_123",
"plan_id": "plan_pro_monthly",
"type": "subscription",
"date": "2024-02-01T00:00:00Z",
"amount_cents": 9900,
"currency": "usd",
"start_date": "2024-02-01T00:00:00Z",
"end_date": "2024-03-01T00:00:00Z",
"payment_date": "2024-02-01T00:00:00Z",
"quantity": 1,
"proration": false,
"data_source": "ds_abc123"
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Your stable invoice ID |
customer_id | string | Yes | Must match an existing customer's id |
plan_id | string | Conditional | Required when type is subscription |
type | string | Yes | subscription, one-time, refund or cancellation (see below) |
date | ISO 8601 | Yes | When the invoice was issued |
amount_cents | integer | Yes | Non-negative integer in minor units. 9900 = $99.00. Refunds use type: "refund", not negative amounts. |
currency | string | Yes | ISO 4217 three-letter, lowercase |
start_date | ISO 8601 | Yes | Start of the billing period this invoice covers |
end_date | ISO 8601 | Yes | End of the billing period |
payment_date | ISO 8601 | No | When the invoice was paid. Omit for unpaid. |
quantity | integer | No | Number of seats / units. Default 1. |
proration | boolean | No | True for mid-cycle partial-period charges |
discount | integer | No | Discount applied, in minor units |
data_source | string | Yes | Data source ID |
Invoice types
| Type | What it represents | Effect on MRR |
|---|---|---|
subscription | A recurring invoice | Diffed against the customer's previous MRR. Becomes new, expansion, contraction or reactivation automatically. |
one-time | Setup fee, professional services, any non-recurring line item | No MRR impact. Counts in cashflow. |
refund | Partial or full refund | Reduces MRR for the period |
cancellation | Marks subscription end | Churns the customer's MRR. Use amount_cents: 0 for a clean cancel without a refund. |
Batch requests
Send multiple objects in one POST by submitting a JSON array (not an object wrapper):
[
{ "id": "cust_1", "name": "Customer One", "email": "[email protected]", "created_date": "2024-01-01T00:00:00Z", "country": "us", "data_source": "ds_abc123" },
{ "id": "cust_2", "name": "Customer Two", "email": "[email protected]", "created_date": "2024-02-01T00:00:00Z", "country": "us", "data_source": "ds_abc123" }
]Batches of 200–500 rows are a safe default. Very large batches may time out at the HTTPS layer.
Response shape
Every POST returns a result array with one entry per input row, in the same order:
{
"result": [
{ "id": "cust_1", "export_status": "inserted" },
{ "id": "cust_2", "export_status": "updated" },
{ "id": "cust_3", "export_status": "error", "export_error": "Invalid country code" }
]
}export_status is one of inserted, updated, unchanged, or error. A 200 status with mixed per-row errors is normal — inspect the array, don't just rely on the HTTP status code.
Common scenarios
You don't tag invoices with their movement type — GrowPanel diffs each new invoice against the customer's history. Some examples:
New customer
Create the plan (idempotent — safe to send every time), the customer, then the first invoice:
# 1. Plan
curl -X POST https://api.growpanel.io/data/plans \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"id":"plan_pro","name":"Pro","billing_freq":"month","currency":"usd","data_source":"ds_abc123"}'
# 2. Customer
curl -X POST https://api.growpanel.io/data/customers \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"id":"cust_123","name":"Acme Inc","email":"[email protected]","created_date":"2024-02-01T00:00:00Z","country":"us","data_source":"ds_abc123"}'
# 3. First invoice
curl -X POST https://api.growpanel.io/data/invoices \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"id":"inv_001","customer_id":"cust_123","plan_id":"plan_pro","type":"subscription","date":"2024-02-01T00:00:00Z","amount_cents":9900,"currency":"usd","start_date":"2024-02-01T00:00:00Z","end_date":"2024-03-01T00:00:00Z","data_source":"ds_abc123"}'Result: New MRR of $99.
Upgrade
Send a new subscription invoice with the higher amount.
{
"id": "inv_002",
"customer_id": "cust_123",
"plan_id": "plan_enterprise",
"type": "subscription",
"date": "2024-03-01T00:00:00Z",
"amount_cents": 29900,
"currency": "usd",
"start_date": "2024-03-01T00:00:00Z",
"end_date": "2024-04-01T00:00:00Z",
"data_source": "ds_abc123"
}Result: Expansion MRR of $200 (= $299 minus the previous $99).
Downgrade
Same shape, lower amount. Result: Contraction MRR.
Cancellation
{
"id": "inv_004",
"customer_id": "cust_123",
"type": "cancellation",
"date": "2024-05-01T00:00:00Z",
"amount_cents": 0,
"currency": "usd",
"start_date": "2024-05-01T00:00:00Z",
"end_date": "2024-05-01T00:00:00Z",
"data_source": "ds_abc123"
}Result: Churned MRR equal to the customer's last subscription amount.
Reactivation
After a customer has churned, a new subscription invoice automatically becomes reactivation — no flag needed:
{
"id": "inv_005",
"customer_id": "cust_123",
"plan_id": "plan_pro",
"type": "subscription",
"date": "2024-08-01T00:00:00Z",
"amount_cents": 9900,
"currency": "usd",
"start_date": "2024-08-01T00:00:00Z",
"end_date": "2024-09-01T00:00:00Z",
"data_source": "ds_abc123"
}Result: Reactivation MRR of $99.
Refund
{
"id": "inv_006",
"customer_id": "cust_123",
"type": "refund",
"date": "2024-08-15T00:00:00Z",
"amount_cents": 4950,
"currency": "usd",
"start_date": "2024-08-15T00:00:00Z",
"end_date": "2024-08-15T00:00:00Z",
"data_source": "ds_abc123"
}Result: $49.50 reduction in MRR for that period. The amount is positive — type is what tells GrowPanel this is a refund.
Historical import
Before turning on an ongoing sync, do a one-time backfill of your full invoice history. Without it you'll see current state but no trends.
GrowPanel uses history for:
- Cohort retention — needs to know when customers started
- Churn analysis — needs to see when they left
- Growth decomposition — needs the full sequence
Import order (only matters on the very first run, when nothing's there yet):
- Plans — invoices reference plans
- Customers — invoices reference customers
- Invoices — in chronological order
After the historical load, ongoing syncs can send incremental changes only.
Ongoing sync
After the historical import, you keep GrowPanel up to date as events happen in your billing system. Pick one of two patterns:
Pattern A: Periodic sync (cron)
A scheduled job that runs every N minutes, asks your source for "what changed since the last successful run", and posts the result to GrowPanel.
The four-step cycle:
1. Read your cursor — when did the last successful run end?
2. Pull from your source — give me customers/invoices changed since the cursor
3. Push to GrowPanel — POST /data/customers, /data/plans, /data/invoices
4. Advance the cursor — only after step 3 succeeds
Pros:
- One scheduled task, one set of credentials, one log to watch.
- Resilient to transient outages — the next run picks up what was missed.
- Easy to backfill: bump the cursor backwards and let the next run resync.
Cons:
- Latency = your cron interval. A new subscription takes up to N minutes to reach GrowPanel.
- Your source needs some way to ask "what changed since X" — a timestamp filter, a cursor, or an event log.
The cursor is usually a timestamp. Only advance it after the GrowPanel calls return success — that way a transient failure means the next run retries the same window instead of skipping data.
Where to store the cursor: Cloudflare KV, a row in your own database, or the source's own "next cursor" tokens. The GrowPanel-side endpoint GET /data/data-sources/{id}/progress reports on in-flight imports but isn't a custom cursor store.
Pattern B: Event-driven (webhooks)
Your billing source posts events to an HTTPS endpoint you operate. That endpoint translates each event into the corresponding GrowPanel call.
provider event → your bridge → POST to GrowPanel → return 200 to provider
Pros:
- Near-instant updates.
- Each event maps cleanly to one or two GrowPanel calls.
- No state to manage — the source decides what to send and when.
Cons:
- You operate an HTTPS endpoint with signature verification.
- You're at the mercy of the source's webhook reliability — missed deliveries become missed data.
Two things that always come up:
Verify the signature before you trust the payload. Most providers sign webhooks with HMAC SHA-256 over the raw request body and a shared secret. Reject anything that doesn't match. The header name and exact signing scheme is provider-specific.
Reply fast. Providers retry on slow or failed responses. Accept the event, do the work, reply 200. If something downstream is broken, reply 5xx so the provider retries — don't return 200 and silently drop the event.
Hybrid: webhooks + cron fallback
Webhooks for low latency, a periodic cron once an hour as a self-healing backstop that catches any missed events. The most robust pattern for production, takes the most code. Worth it once a single customer's data matters enough that "we missed an event" is a real problem.
Where to host the pipeline
Pick whichever you're already comfortable with:
| Platform | Best for | Notes |
|---|---|---|
| Cloudflare Workers | Both cron and webhook patterns | Built-in cron triggers, KV for state, free tier is generous, HTTPS endpoint for free. Used in the examples below. |
| AWS Lambda + EventBridge | Teams already on AWS | EventBridge for the cron trigger, API Gateway in front for the webhook. |
| Vercel / Netlify cron | Already deployed there | Easy if your stack is there; check pricing for cron frequency. |
| GitHub Actions | Lowest volume + you're in GitHub | The 5-minute minimum interval and shared-runner queue make this fine for hourly syncs, painful below 15 minutes. |
| A long-running server | You have one anyway | node-cron, python-apscheduler and friends. |
| n8n / Make / Zapier | Low-code / non-engineer-owned syncs | Both can call HTTPS endpoints on a schedule and POST to GrowPanel. Good for prototypes; harder to debug at scale. |
Skeleton code
The code below is intentionally bare — adapt the source-specific bits and add error handling for your environment. Comments mark where the real work goes.
JavaScript (Cloudflare Worker — cron pattern)
wrangler.toml:
name = "growpanel-sync"
main = "src/index.js"
compatibility_date = "2025-01-01"
[triggers]
crons = ["*/15 * * * *"] # every 15 minutes
[[kv_namespaces]]
binding = "SYNC_STATE"
id = "..."src/index.js:
const GROWPANEL_BASE = "https://api.growpanel.io";
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(runSync(env));
},
};
async function runSync(env) {
// --- 1. Read cursor ----------------------------------------------------
const cursor = (await env.SYNC_STATE.get("cursor")) ?? thirtyDaysAgo();
const runStartedAt = new Date().toISOString();
try {
// --- 2. Pull changes from your billing source ---------------------
// Replace with whatever your source exposes. Most have a `?updated_since=...`
// filter or a list_events endpoint. Page through if needed.
const changes = await fetchSourceChangesSince(env, cursor);
// --- 3. Transform and push ----------------------------------------
// Order matters on the very first run: plans, then customers, then invoices.
// After that the order is flexible because everything already exists.
if (changes.plans.length)
await postBatch(env, "/data/plans", changes.plans.map(toPlan));
if (changes.customers.length)
await postBatch(env, "/data/customers", changes.customers.map(toCustomer));
if (changes.invoices.length)
await postBatch(env, "/data/invoices", changes.invoices.map(toInvoice));
// --- 4. Advance cursor — only after pushes succeeded --------------
await env.SYNC_STATE.put("cursor", runStartedAt);
} catch (err) {
// TODO: Alert (Slack, email, Sentry). Don't advance the cursor — the
// next run will retry the same window. Repeated failures need a human.
console.error("sync failed", err);
throw err;
}
}
// --- Field mappers — source-specific knowledge lives here -----------------
function toCustomer(row, env) {
return {
id: row.external_id,
name: row.name || null, // name OR email is required
email: row.email || null,
created_date: row.created_at, // ISO 8601
country: row.country_iso2, // 2 letters, lowercase
state: row.us_state_lower || null,
trial_started: row.trial_start_at || null,
custom_variables: row.metadata || {},
data_source: env.DATA_SOURCE_ID,
};
}
function toPlan(row, env) {
return {
id: row.plan_id, // becomes external_id on the stored row
name: row.plan_name,
billing_freq: row.interval, // day/week/month/year
billing_freq_count: row.interval_count ?? 1,
currency: row.currency_code, // 3 letters lowercase
data_source: env.DATA_SOURCE_ID,
};
}
function toInvoice(row, env) {
return {
id: row.invoice_id,
customer_id: row.customer_external_id,
plan_id: row.plan_id || null, // required if type is "subscription"
type: row.type, // subscription/one-time/refund/cancellation
date: row.invoice_date,
amount_cents: row.amount_minor_units,
currency: row.currency_code,
start_date: row.period_start,
end_date: row.period_end,
payment_date: row.paid_at || null,
quantity: row.qty ?? 1,
proration: row.is_proration === true,
data_source: env.DATA_SOURCE_ID,
};
}
// --- HTTP plumbing --------------------------------------------------------
async function postBatch(env, path, rows) {
const CHUNK = 500;
for (let i = 0; i < rows.length; i += CHUNK) {
const res = await fetch(GROWPANEL_BASE + path, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.GROWPANEL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(rows.slice(i, i + CHUNK)),
});
if (!res.ok) {
// TODO: 5xx → retry with exponential backoff. 4xx → log + alert
// (it's a data-shape bug, not a transient failure).
throw new Error(`POST ${path} returned ${res.status}: ${await res.text()}`);
}
// GrowPanel returns per-row results. Partial failures live here.
const { result } = await res.json();
const errors = result.filter(r => r.export_status === "error");
if (errors.length) {
// TODO: Alert. These won't fix themselves on retry. Common causes:
// missing required fields, references to customers/plans that
// haven't been pushed yet.
console.warn(`${errors.length} rows failed; first 3:`, errors.slice(0, 3));
}
}
}
async function fetchSourceChangesSince(env, cursor) {
// TODO: Replace with your actual source API call. Page through results.
throw new Error("Implement me");
}
function thirtyDaysAgo() {
return new Date(Date.now() - 30 * 86400_000).toISOString();
}JavaScript (Cloudflare Worker — webhook pattern)
For event-driven syncs. Same Worker, different fetch handler:
const GROWPANEL_BASE = "https://api.growpanel.io";
export default {
async fetch(request, env) {
if (request.method !== "POST")
return new Response("Method not allowed", { status: 405 });
// Read the raw body BEFORE parsing — signature verification needs the
// exact bytes the sender hashed.
const rawBody = await request.text();
// --- 1. Verify signature ------------------------------------------
const signatureHeader = request.headers.get("X-Provider-Signature");
const valid = await verifyHmacSha256(rawBody, signatureHeader, env.WEBHOOK_SECRET);
if (!valid) return new Response("Invalid signature", { status: 401 });
// --- 2. Parse + route ---------------------------------------------
const event = JSON.parse(rawBody);
try {
switch (event.type) {
case "subscription.created": await onSubscriptionCreated(env, event); break;
case "subscription.updated": await onSubscriptionUpdated(env, event); break;
case "subscription.canceled": await onSubscriptionCanceled(env, event); break;
case "invoice.paid": await onInvoicePaid(env, event); break;
default:
console.log("ignoring event", event.type);
}
return new Response("ok", { status: 200 });
} catch (err) {
// 5xx tells the provider to retry. Use only for transient failures
// — permanent data-shape errors will keep failing.
console.error("event handler failed", event.type, err);
return new Response("internal error", { status: 500 });
}
},
};
async function onSubscriptionCreated(env, event) {
const sub = event.data;
// Ensure customer exists (idempotent), then post the first invoice.
await postToGrowPanel(env, "/data/customers", {
id: sub.customer_id,
name: sub.customer_name,
email: sub.customer_email,
created_date: sub.customer_created_at,
country: sub.customer_country,
data_source: env.DATA_SOURCE_ID,
});
await postToGrowPanel(env, "/data/invoices", {
id: sub.first_invoice_id,
customer_id: sub.customer_id,
plan_id: sub.plan_id,
type: "subscription",
date: sub.started_at,
amount_cents: sub.amount_cents,
currency: sub.currency,
start_date: sub.period_start,
end_date: sub.period_end,
data_source: env.DATA_SOURCE_ID,
});
}
async function onSubscriptionCanceled(env, event) {
const sub = event.data;
await postToGrowPanel(env, "/data/invoices", {
id: `${sub.id}-cancel`,
customer_id: sub.customer_id,
type: "cancellation",
date: sub.canceled_at,
amount_cents: 0,
currency: sub.currency,
start_date: sub.canceled_at,
end_date: sub.canceled_at,
data_source: env.DATA_SOURCE_ID,
});
}
async function onSubscriptionUpdated(env, event) {
// TODO: post a new subscription invoice with the new amount.
// GrowPanel classifies expansion vs contraction automatically.
}
async function onInvoicePaid(env, event) {
// TODO: post subscription invoice with payment_date set.
}
async function postToGrowPanel(env, path, body) {
const res = await fetch(GROWPANEL_BASE + path, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.GROWPANEL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`GrowPanel ${path} returned ${res.status}: ${await res.text()}`);
// TODO: inspect result[].export_status for per-row errors as above.
}
// --- HMAC SHA-256 helper (provider-agnostic) ------------------------------
async function verifyHmacSha256(rawBody, headerValue, secret) {
// Adapt for your provider's exact format. Many use `t=<timestamp>,v1=<sig>`
// and require rejecting events older than a few minutes (replay protection).
if (!headerValue) return false;
const expected = await hmacHex(secret, rawBody);
return timingSafeEqual(expected, headerValue);
}
async function hmacHex(secret, payload) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
return mismatch === 0;
}Python (cron pattern)
A script for cron, apscheduler, AWS Lambda on an EventBridge rule, or whatever you run scheduled jobs in.
import os
from datetime import datetime, timedelta, timezone
import httpx
GROWPANEL_BASE = "https://api.growpanel.io"
API_KEY = os.environ["GROWPANEL_API_KEY"]
DATA_SOURCE_ID = os.environ["GROWPANEL_DATA_SOURCE_ID"]
def run_sync():
cursor = read_cursor() or thirty_days_ago()
run_started_at = datetime.now(timezone.utc).isoformat()
try:
changes = fetch_source_changes_since(cursor)
# Order matters on the first run: plans before customers; customers
# before invoices that reference them.
if changes["plans"]:
post_batch("/data/plans", [to_plan(p) for p in changes["plans"]])
if changes["customers"]:
post_batch("/data/customers", [to_customer(c) for c in changes["customers"]])
if changes["invoices"]:
post_batch("/data/invoices", [to_invoice(i) for i in changes["invoices"]])
write_cursor(run_started_at)
except Exception:
# TODO: Alert (Slack, Sentry, log aggregator). Don't update the cursor.
raise
def post_batch(path: str, rows: list[dict]) -> None:
CHUNK = 500
with httpx.Client(timeout=30) as client:
for i in range(0, len(rows), CHUNK):
resp = client.post(
GROWPANEL_BASE + path,
json=rows[i:i + CHUNK],
headers={"Authorization": f"Bearer {API_KEY}"},
)
# TODO: backoff/retry on 5xx; let 4xx bubble (data-shape bug).
resp.raise_for_status()
errors = [r for r in resp.json()["result"] if r.get("export_status") == "error"]
if errors:
# TODO: alert — these won't self-heal.
print(f"{len(errors)} rows failed; first 3: {errors[:3]}")
def to_customer(row: dict) -> dict:
return {
"id": row["external_id"],
"name": row.get("name"),
"email": row.get("email"),
"created_date": row["created_at"],
"country": row["country_iso2"],
"state": row.get("us_state_lower"),
"trial_started": row.get("trial_start_at"),
"custom_variables": row.get("metadata") or {},
"data_source": DATA_SOURCE_ID,
}
def to_plan(row: dict) -> dict:
return {
"id": row["plan_id"],
"name": row["plan_name"],
"billing_freq": row["interval"],
"billing_freq_count": row.get("interval_count", 1),
"currency": row["currency_code"],
"data_source": DATA_SOURCE_ID,
}
def to_invoice(row: dict) -> dict:
return {
"id": row["invoice_id"],
"customer_id": row["customer_external_id"],
"plan_id": row.get("plan_id"),
"type": row["type"],
"date": row["invoice_date"],
"amount_cents": row["amount_minor_units"],
"currency": row["currency_code"],
"start_date": row["period_start"],
"end_date": row["period_end"],
"payment_date": row.get("paid_at"),
"quantity": row.get("qty", 1),
"proration": row.get("is_proration", False),
"data_source": DATA_SOURCE_ID,
}
def fetch_source_changes_since(cursor: str) -> dict:
# TODO: replace with your source API call. Page through results.
raise NotImplementedError
def read_cursor() -> str | None:
# TODO: read from your KV / database / file.
...
def write_cursor(value: str) -> None:
# TODO: persist atomically. Only call after all posts succeeded.
...
def thirty_days_ago() -> str:
return (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
if __name__ == "__main__":
run_sync()Python (webhook pattern, Flask)
import os, hmac, hashlib
import httpx
from flask import Flask, request, abort
app = Flask(__name__)
GROWPANEL_BASE = "https://api.growpanel.io"
API_KEY = os.environ["GROWPANEL_API_KEY"]
DATA_SOURCE_ID = os.environ["GROWPANEL_DATA_SOURCE_ID"]
WEBHOOK_SECRET = os.environ["PROVIDER_WEBHOOK_SECRET"]
@app.post("/webhook")
def handle_webhook():
raw = request.get_data()
sig = request.headers.get("X-Provider-Signature", "")
if not verify_hmac_sha256(raw, sig, WEBHOOK_SECRET):
abort(401)
event = request.get_json(force=True)
try:
dispatch(event)
except Exception:
app.logger.exception("event failed")
abort(500) # 5xx → provider retries
return "", 200
def dispatch(event: dict) -> None:
t = event.get("type")
data = event.get("data", {})
if t == "subscription.created": on_subscription_created(data)
elif t == "subscription.updated": on_subscription_updated(data)
elif t == "subscription.canceled":on_subscription_canceled(data)
elif t == "invoice.paid": on_invoice_paid(data)
else: app.logger.info("ignoring %s", t)
def on_subscription_created(sub: dict) -> None:
post("/data/customers", {
"id": sub["customer_id"],
"name": sub.get("customer_name"),
"email": sub.get("customer_email"),
"created_date": sub["customer_created_at"],
"country": sub["customer_country"],
"data_source": DATA_SOURCE_ID,
})
post("/data/invoices", {
"id": sub["first_invoice_id"],
"customer_id": sub["customer_id"],
"plan_id": sub["plan_id"],
"type": "subscription",
"date": sub["started_at"],
"amount_cents": sub["amount_cents"],
"currency": sub["currency"],
"start_date": sub["period_start"],
"end_date": sub["period_end"],
"data_source": DATA_SOURCE_ID,
})
def on_subscription_canceled(sub: dict) -> None:
post("/data/invoices", {
"id": f"{sub['id']}-cancel",
"customer_id": sub["customer_id"],
"type": "cancellation",
"date": sub["canceled_at"],
"amount_cents": 0,
"currency": sub["currency"],
"start_date": sub["canceled_at"],
"end_date": sub["canceled_at"],
"data_source": DATA_SOURCE_ID,
})
def on_subscription_updated(sub: dict) -> None: ... # TODO
def on_invoice_paid(inv: dict) -> None: ... # TODO
def post(path: str, body: dict | list) -> None:
resp = httpx.post(
GROWPANEL_BASE + path,
json=body,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
# TODO: inspect resp.json()["result"][*]["export_status"] for per-row errors.
def verify_hmac_sha256(raw: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)Idempotency, retries and error handling
Stable IDs deduplicate for you
Every record GrowPanel stores is keyed by your external id. POSTing the same object twice is a safe upsert. This means:
- Retries are safe. If a 5xx leaves you unsure whether GrowPanel processed the batch, just send it again.
- Replays are safe. Re-running a cron window after a failure won't create duplicates.
- Backfills are safe. Sending your full historical invoice list re-imports cleanly.
Backoff on 5xx
For transient failures from GrowPanel (5xx, network errors, timeouts), retry with exponential backoff: 1s, 2s, 4s, 8s, 16s. After 5 failures, alert and stop — for cron, don't advance the cursor; for webhooks, reply 5xx so the provider retries.
Don't retry 4xx
A 4xx means GrowPanel rejected the payload for a permanent reason — missing field, invalid type, unknown customer reference. Retrying won't fix it. Log enough context to debug it offline and continue with the next batch.
Per-row failures inside a 200 response
A 200 response can still contain individual export_status: "error" rows. Always inspect the result array — these are usually data-quality issues in your transform layer, not GrowPanel bugs.
Forward-reference errors
Invoices reference customers and plans. If you send an invoice for a customer GrowPanel hasn't seen yet, you'll get Customer ID cus_xxx does not exist. Two ways to handle:
- In a cron pipeline: post in dependency order — plans, then customers, then invoices.
- In a webhook pipeline: post the customer first inside the same handler (cheap and idempotent), then the invoice.
Testing
Start with a single customer end-to-end
For your first test, pipe through one customer and one invoice. Confirm export_status: "inserted", then check the customer detail page in GrowPanel.
Use a separate data source for staging
Create a second data source of type api and point your test environment at it. Real syncs and test syncs never touch the same records that way. The access_token returned on creation is the API key tied to that source — use it as the staging key.
Spot-check totals
After a backfill, the MRR chart, customer count, and the customer list should match your source's totals to within rounding. If they don't, the customer detail page in GrowPanel shows the invoice history we recorded — usually enough to find what's missing or duplicated.
Replay a window
A good sanity check before going to production: pick a week from the past, manually walk your cursor backwards, run a sync. The numbers shouldn't change — idempotent upserts mean the same input produces the same state.
Going to production
A short checklist before pointing the pipeline at live data:
- Separate keys per environment. Staging key and prod key as separate secrets.
- Alerts on consecutive failures. Three in a row needs a human.
- Log retention. Keep at least 7 days of per-batch logs (which rows were sent, what came back). Makes debugging a missing customer 100× faster.
- Cron interval that matches your business. 5–15 minutes for high-volume self-serve; hourly is plenty for B2B annuals.
- A documented rollback. If the pipeline corrupts data, Settings → Data sources → your data source → Reset clears the source's data without removing the connection — then re-import. Worth knowing where the button is before you need it.
API reference
For complete endpoint documentation — every field, error codes, rate limits, and try-it-out — see the interactive REST API reference. The data ingestion endpoints live under Data — Customers, Data — Plans and Data — Invoices in the sidebar. Each has auto-generated code samples in JavaScript, Python and Go.
Need help?
If you'd like a review of your integration design before shipping, email [email protected] with a brief description of your setup and the data shape you're working with. We're happy to spot issues early.