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:

TypeWhat it represents
CustomersWho is paying you
PlansWhat they're paying for (pricing, billing interval)
InvoicesRecords 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

  1. In GrowPanel, navigate to Settings → Data sources.
  2. Click Add data source → select Custom API.
  3. Name it (e.g. "Internal billing platform").
  4. 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

EndpointMethodPurpose
/data/customersPOSTCreate or update customers (bulk-friendly)
/data/plansPOSTCreate or update plans (bulk-friendly)
/data/invoicesPOSTCreate 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"
}
FieldTypeRequiredDescription
idstringYesYour stable customer ID
namestringConditionalName or email is required
emailstringConditionalName or email is required
created_dateISO 8601YesWhen the customer first appeared
countrystringYesISO 3166 alpha-2 code, lowercase (us, de, dk)
statestringNoUS/CA state code, lowercase
trial_startedISO 8601NoSets status to trialing until the first paid invoice
custom_variablesobjectNoString-keyed map; surfaces as filterable variables
data_sourcestringYesData source ID

Plan object

{
"id": "plan_pro_monthly",
"name": "Pro Monthly",
"billing_freq": "month",
"billing_freq_count": 1,
"currency": "usd",
"data_source": "ds_abc123"
}
FieldTypeRequiredDescription
idstringYesYour stable plan ID. Stored as external_id.
namestringYesDisplay name
billing_freqstringYesday, week, month or year
billing_freq_countintegerNoDefaults to 1. Use 3 + month for quarterly, 12 + month for an annual-modeled-monthly plan.
currencystringYesISO 4217 three-letter code, lowercase
data_sourcestringYesData 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"
}
FieldTypeRequiredDescription
idstringYesYour stable invoice ID
customer_idstringYesMust match an existing customer's id
plan_idstringConditionalRequired when type is subscription
typestringYessubscription, one-time, refund or cancellation (see below)
dateISO 8601YesWhen the invoice was issued
amount_centsintegerYesNon-negative integer in minor units. 9900 = $99.00. Refunds use type: "refund", not negative amounts.
currencystringYesISO 4217 three-letter, lowercase
start_dateISO 8601YesStart of the billing period this invoice covers
end_dateISO 8601YesEnd of the billing period
payment_dateISO 8601NoWhen the invoice was paid. Omit for unpaid.
quantityintegerNoNumber of seats / units. Default 1.
prorationbooleanNoTrue for mid-cycle partial-period charges
discountintegerNoDiscount applied, in minor units
data_sourcestringYesData source ID

Invoice types

TypeWhat it representsEffect on MRR
subscriptionA recurring invoiceDiffed against the customer's previous MRR. Becomes new, expansion, contraction or reactivation automatically.
one-timeSetup fee, professional services, any non-recurring line itemNo MRR impact. Counts in cashflow.
refundPartial or full refundReduces MRR for the period
cancellationMarks subscription endChurns 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):

  1. Plans — invoices reference plans
  2. Customers — invoices reference customers
  3. 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:

  1. 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.

  2. 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:

PlatformBest forNotes
Cloudflare WorkersBoth cron and webhook patternsBuilt-in cron triggers, KV for state, free tier is generous, HTTPS endpoint for free. Used in the examples below.
AWS Lambda + EventBridgeTeams already on AWSEventBridge for the cron trigger, API Gateway in front for the webhook.
Vercel / Netlify cronAlready deployed thereEasy if your stack is there; check pricing for cron frequency.
GitHub ActionsLowest volume + you're in GitHubThe 5-minute minimum interval and shared-runner queue make this fine for hourly syncs, painful below 15 minutes.
A long-running serverYou have one anywaynode-cron, python-apscheduler and friends.
n8n / Make / ZapierLow-code / non-engineer-owned syncsBoth 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.