GUIDES
Your own billing (API)
Without Stripe or a supported billing tool, your backend sends subscriptions and payment attempts, and applies what NoChurn asks for through webhooks.
Choose billing by API when your app does not bill with Stripe or one of the supported tools: another processor, or billing you built yourself. NoChurn never charges a card it does not hold. Your backend tells NoChurn what happens; NoChurn decides and asks your backend to act.
Set the account’s billing source once: PATCH /v1/account { "billing_source": "api" } (or pick “API” at onboarding).
What your backend sends
Subscriptions. POST /v1/subscriptions when a subscription is created or changes:
| Field | |
|---|---|
external_id |
Your subscription id |
customer |
Your user id, or { external_id, email } to create the customer at the same time |
plan |
Plan name |
amount, currency |
Price per interval, in cents |
interval |
month or year |
status |
The subscription status |
current_period_end |
Next renewal, ISO 8601 |
Later changes go to PATCH /v1/subscriptions/{id}.
Payment attempts. POST /v1/payments for every attempt, failed or succeeded:
| Field | |
|---|---|
external_id |
Your payment id. Idempotent: the same id is recorded once |
customer, subscription |
Your ids |
invoice |
Your invoice id: groups the attempts of one bill |
amount, currency |
In cents |
status |
failed or succeeded |
decline_code |
The processor’s decline code, e.g. insufficient_funds |
invoice_url |
The page of your app where the customer updates their card. The recovery emails link to it |
A failed payment opens the recovery flow. A succeeded one on the same invoice closes it as recovered.
curl -X POST https://api.nochurn.sh/v1/subscriptions -H "Authorization: Bearer $NOCHURN_API_KEY" -H "Content-Type: application/json" -d '{"external_id":"sub_123","customer":{"external_id":"user_123","email":"jane@example.com"},"plan":"Pro","amount":4900,"currency":"usd","interval":"month","status":"active","current_period_end":"2026-12-05T00:00:00Z"}'
curl -X POST https://api.nochurn.sh/v1/payments -H "Authorization: Bearer $NOCHURN_API_KEY" -H "Content-Type: application/json" -d '{"external_id":"pay_123","customer":"user_123","subscription":"sub_123","invoice":"inv_123","amount":4900,"currency":"usd","status":"failed","decline_code":"insufficient_funds","invoice_url":"https://app.example.com/billing"}'// Node 18+, server side only. Env: NOCHURN_API_KEY, NOCHURN_API_URL (optional). Amounts in cents.
const NOCHURN_API_URL = process.env.NOCHURN_API_URL || "https://api.nochurn.sh";
async function post(path, body) {
const response = await fetch(`${NOCHURN_API_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.NOCHURN_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(`NoChurn: ${data?.error?.message} ${data?.error?.fix ?? ""}`);
return data;
}
// sub: { id, userId, email, plan, amountCents, currency, interval: "month" | "year", status, currentPeriodEnd: Date }
export function recordSubscription(sub) {
return post("/v1/subscriptions", {
external_id: String(sub.id), customer: { external_id: String(sub.userId), email: sub.email },
plan: sub.plan, amount: sub.amountCents, currency: sub.currency, interval: sub.interval, status: sub.status,
current_period_end: sub.currentPeriodEnd.toISOString(),
});
}
// payment: { id, userId, subscriptionId, invoiceId, amountCents, currency, status: "failed" | "succeeded", declineCode?, invoiceUrl }
export function recordPayment(payment) {
return post("/v1/payments", {
external_id: String(payment.id), customer: String(payment.userId), subscription: String(payment.subscriptionId),
invoice: String(payment.invoiceId), amount: payment.amountCents, currency: payment.currency, status: payment.status,
decline_code: payment.declineCode, invoice_url: payment.invoiceUrl,
});
}# Python, requests, server side only. Amounts in cents.
import os
import requests
NOCHURN_API_URL = os.environ.get("NOCHURN_API_URL", "https://api.nochurn.sh")
def _post(path, body):
response = requests.post(f"{NOCHURN_API_URL}{path}", json={k: v for k, v in body.items() if v is not None}, timeout=10,
headers={"Authorization": f"Bearer {os.environ['NOCHURN_API_KEY']}", "Content-Type": "application/json"})
if response.status_code >= 300:
error = response.json().get("error", {})
raise RuntimeError(f"NoChurn: {error.get('message')} {error.get('fix', '')}")
return response.json()
def record_subscription(sub):
return _post("/v1/subscriptions", {
"external_id": str(sub["id"]), "customer": {"external_id": str(sub["user_id"]), "email": sub.get("email")},
"plan": sub["plan"], "amount": sub["amount_cents"], "currency": sub["currency"], "interval": sub["interval"],
"status": sub["status"], "current_period_end": sub["current_period_end"].isoformat(),
})
def record_payment(payment):
return _post("/v1/payments", {
"external_id": str(payment["id"]), "customer": str(payment["user_id"]), "subscription": str(payment["subscription_id"]),
"invoice": str(payment["invoice_id"]), "amount": payment["amount_cents"], "currency": payment["currency"],
"status": payment["status"], "decline_code": payment.get("decline_code"), "invoice_url": payment.get("invoice_url"),
})# Ruby, Net::HTTP, server side only. Amounts in cents.
require "net/http"
require "json"
require "uri"
require "time"
def nochurn_post(path, body)
uri = URI("#{ENV.fetch('NOCHURN_API_URL', 'https://api.nochurn.sh')}#{path}")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NOCHURN_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(body.compact)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
data = JSON.parse(response.body) rescue nil
raise "NoChurn: #{data&.dig('error', 'message')} #{data&.dig('error', 'fix')}" if response.code.to_i >= 300
data
end
def record_subscription(sub)
nochurn_post("/v1/subscriptions", { external_id: sub[:id].to_s, customer: { external_id: sub[:user_id].to_s, email: sub[:email] },
plan: sub[:plan], amount: sub[:amount_cents], currency: sub[:currency], interval: sub[:interval],
status: sub[:status], current_period_end: sub[:current_period_end].utc.iso8601 })
end
def record_payment(payment)
nochurn_post("/v1/payments", { external_id: payment[:id].to_s, customer: payment[:user_id].to_s, subscription: payment[:subscription_id].to_s,
invoice: payment[:invoice_id].to_s, amount: payment[:amount_cents], currency: payment[:currency],
status: payment[:status], decline_code: payment[:decline_code], invoice_url: payment[:invoice_url] })
endWhat NoChurn asks your backend to do
NoChurn sends webhooks your backend applies. Each one carries an idempotency_key: pass it on, so the action happens once even when the webhook is delivered twice.
| Event | Your backend |
|---|---|
billing.retry_requested |
Attempts the payment of the invoice now, then reports the result with POST /v1/payments |
billing.cancel_requested |
Cancels the subscription. reason is customer_cancelled (at_period_end says when) or recovery_lost (cancel at the end of the period) |
billing.discount_requested |
Applies the accepted discount offer to the subscription |
billing.pause_requested |
Pauses the subscription |
billing.downgrade_requested |
Moves the subscription to the offered plan |
Payloads: Webhook events › Billing by API. A cancellation the customer asked for must always be carried out (click to cancel).
Check it in test mode
With a test key, POST /v1/sandbox/customers creates a customer billed by API, and POST /v1/sandbox/failed_payment plays a failed payment: see Test mode and sandbox.