Take payment with Stripe

Spondo Billing works out what each customer owes and invoices them; Stripe collects the money. Connect your Stripe account and checkouts take payment on Stripe Checkout, customers who've given Stripe a card are charged automatically, and Stripe Tax, Smart Retries and dunning emails do the rest. Invoices and payments sync back, so you see them in Spondo Billing and its API.

Without Stripe, Spondo Billing still invoices every order, with a hosted page and email reminders, and you record the payments you receive.

Connect Stripe

  1. In Stripe, go to Developers → API keys → Create restricted key, and give it write access to: Customers, Products, Prices, Checkout Sessions, Subscriptions, Invoices, Invoice items, Payment Methods, SetupIntents, Billing meters, Meter events, Credit grants, Customer portal and Webhook Endpoints. Read access to Account is optional; it shows your account's name in the dashboard.
  2. In the Spondo Billing dashboard, open Integrations → Stripe and paste the key (rk_live_…, or rk_test_… for a Stripe sandbox).

Spondo Billing checks the key with Stripe and stores it encrypted. It also creates a webhook endpoint in your Stripe account for the events it needs, and keeps that endpoint's signing secret, so there is nothing to set up in Stripe's webhook settings. Reconnecting replaces the endpoint and disconnecting deletes it.

A secret key (sk_…) works too, but gives Spondo Billing more access than it needs.

Checkouts

A checkout is a link you send a customer to buy a plan. Create one with the products (and optionally the plans) to offer and where to send the customer afterwards:

const checkout = await spondo.checkouts.create({
  products: [{ id: "prod_7Hk2LmQ9xR4tVb1N", plans: [{ id: "plan_4Fd8sK1mW9qZ2xCv" }] }],
  externalCustomerId: "acme-legal",
  successUrl: "https://documind.example/welcome?session={CHECKOUT_SESSION_ID}",
  cancelUrl: "https://documind.example/pricing",
});
// Send the customer to checkout.url
curl https://billing.getspondo.com/api/v1/checkouts \
  --request POST \
  --header "Authorization: Bearer $SPONDO_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "products": [{ "id": "prod_7Hk2LmQ9xR4tVb1N", "plans": [{ "id": "plan_4Fd8sK1mW9qZ2xCv" }] }],
    "externalCustomerId": "acme-legal",
    "successUrl": "https://documind.example/welcome?session={CHECKOUT_SESSION_ID}"
  }'

What the customer sees at url depends on the checkout:

The link opens
Stripe connected, a customer named, one plan, single useStripe Checkout, ready to pay for the plan.
Stripe connected, otherwise (no customer yet, several plans to choose from, or a reusable link)Spondo Billing's checkout page: the plans, described in words ("$99.00 a month, with 500 credits", "1 credit per document_uploaded"), and, for a public link, fields for the buyer's company and billing email. Continue to payment takes them to Stripe Checkout for the plan they chose.
No StripeThe same page, with Start subscription: the customer starts at once and pays on invoice.

If the order would clash with one the customer already has running (both pricing the same event), they're refused before paying: POST /checkouts answers 409 CUSTOMER_HAS_ACTIVE_ORDER for a one-plan link to Stripe Checkout, and the checkout page refuses the plan when they choose it. When the customer finishes, Spondo Billing activates their order and redirects them to successUrl, with {CHECKOUT_SESSION_ID} replaced by the checkout session's ID (cs_…). Stripe sends them back to cancelUrl if they leave Stripe Checkout. A customer named with an externalCustomerId that doesn't exist yet is created for you.

OptionWhat it does
customerId / externalCustomerIdWho is buying. Leave both out for a public link: the buyer gives their name and email, which creates the customer.
products[].plansThe plans to offer for each product; without them, the product's default plan.
currencyThe currency to charge in; otherwise the offered plans' currencies.
expiresAtWhen the link stops working. Default: 24 hours.
singleUseDefault true: the checkout completes after one purchase. Set false for a link many customers can use, each getting their own order.
collectAddress, collectPhoneAsk for a billing address (default on) and a phone number (default off) on Stripe Checkout.
successTitle, successMessageWhat the link shows after it's completed.

A checkout's status is active, completed, expired or archived. Delete a checkout to archive it, which closes the link; a completed checkout can't be archived. Each customer's attempt is a checkout session, which records the plan they chose and, once completed, their order.

Listen for the billing-checkout-completed webhook to know when to give the customer what they bought.

Invoices

Spondo Billing bills each order in one of two ways.

Orders started through Stripe Checkout

When a checkout completes through Stripe, its order runs as a Stripe subscription on the plan's prices:

  • Stripe invoices each period for the plan's fee. A plan whose fees mix billing frequencies is billed monthly there.
  • Credits used beyond the plan's allowance are reported to a Stripe billing meter as signals arrive, so the overage is on the invoice.
  • The order's other charges, such as usage priced per unit or in tiers, cost-plus and one-time fees, are added to each renewal invoice while it's still a draft.
  • Stripe Tax calculates tax if you've set it up, and Smart Retries and Stripe's emails chase failed payments.

Spondo Billing receives Stripe's invoice events (invoice.created, invoice.finalized, invoice.updated, invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible) and keeps a copy of each invoice, with its lines, totals, status and Stripe's hosted invoice page as paymentLink. Each collection Stripe makes, or fails to make, is recorded as a payment, and each refund Stripe makes on one (charge.refunded) as a credit note. Credit grants you make are mirrored to Stripe as credit grants, so they reduce the overage Stripe bills.

Every other order

Spondo Billing's billing run invoices every other order itself, on the order's own cycle: from its start date, then on each billing anchor day (billingAnchor, else the start date's day). Each invoice comes from the order's lines: fees in advance or in arrears as each charge says, usage and overage in arrears, and proration from amendments. Anything that changes after an invoice, such as late signals, a backdated start or a corrected credit balance, lands as a line on the next one, so nothing is billed twice. After an order ends, anything still owed in the next 90 days (counted again if its end date moves) goes on a true-up invoice. Invoices are numbered INV-1001, INV-1002 and so on.

What happens to each invoice follows the order's terms:

Order termEffect
autoPostInvoicesPost invoices as soon as they're made. Otherwise they wait as drafts until you post them from the dashboard.
paymentTermsWhen a posted invoice is due: DueOnReceipt, Net15 through Net90, or 14 days when unset.
autoSendBillingEmailsEmail each posted invoice to the order's billing contacts, and send reminders for unpaid ones on the due date and 3, 7 and 14 days after.

A posted invoice has a hosted page the customer can open and print or save as a PDF; its link is the invoice's paymentLink. If Stripe knows the customer, because they've saved a card or been through Stripe Checkout, the posted invoice is handed to Stripe to collect: charged to their default card, or sent to pay online by its due date, with Stripe's retries. paymentLink is then Stripe's hosted invoice page, and the invoice's status follows Stripe's.

Tax on invoices Spondo Billing collects itself is your account's flat rate, or none for customers that are tax exempt or reverse-charged. For tax by address, collect through Stripe with Stripe Tax. Voiding an invoice writes its charges off: the next invoice doesn't bill them again.

Recording payments

When a customer pays outside Stripe, by bank transfer for example, record the payment and allocate it to the invoice's lines:

const payment = await spondo.request("POST", "/payments", {
  body: { externalCustomerId: "acme-legal", amount: 11988, currency: "USD", paymentType: "bankTransfer" },
});

await spondo.request("POST", "/payment-allocations", {
  body: {
    paymentId: payment.id,
    allocations: [
      { invoiceLineId: "inv_3Rk8Wq1Zp6Ty0Xm2-0", amount: 10692 },
      { invoiceLineId: "inv_3Rk8Wq1Zp6Ty0Xm2-1", amount: 1296 },
    ],
  },
});

Amounts are integer minor units, and an allocation that would put more on an invoice than it has left to pay is refused with 400. Once allocations reach the invoice's total, the invoice is paid and the billing-invoice-paid webhook is sent. If Stripe was collecting the invoice, it's marked paid in Stripe first, so Stripe stops collecting; if Stripe refuses, nothing is recorded.

Refunds

To give back money paid on an invoice, in full or in part, create a credit note against it. When Stripe collected the payment, the money goes back to the card through Stripe; otherwise the refund is recorded and you return the money the way the customer paid. Send an idempotencyKey so a retried request can't refund twice:

const creditNote = await spondo.request("POST", "/credit-notes", {
  body: { invoiceId: "inv_3Rk8Wq1Zp6Ty0Xm2", amount: 1499, reason: "Money-back guarantee", idempotencyKey: "case-4821-refund" },
});

You can refund at most what was paid less what's already been refunded. Refunds made in Stripe's dashboard become credit notes on their own. The invoice's creditNotesTotal shows what's been refunded, revenue drops by each refund on the day it's made (so does what the customer paid on their value receipt), and the billing-invoice-refunded webhook is sent. Credits the invoice granted stay granted. The dashboard's Refund button on a paid invoice does the same.

Reading invoices

An invoice whose total is zero or less, such as a credit after a downgrade, reads as paid once posted and is emailed as a credit, with nothing to pay. GET /invoices lists invoices, filtered by customer, order, status, payment status, dates and more; GET /invoices/{id}/lines lists an invoice's lines. An invoice's status is draft, posted or voided, and paymentStatus says whether a posted invoice is pending, partiallyPaid, paid or overdue. All amounts are integer minor units. You can set an invoice's purchaseOrderReference and metadata with PUT /invoices/{id}.

Payment methods

To charge a customer's card or bank account without a checkout, save it as a payment method. Collect it in your front end with Stripe.js, which gives you a confirmation token (ctoken_…), then send that to POST /payment-methods from your server. Spondo Billing confirms a SetupIntent for off-session use and saves the method. If the response's setupIntent.status is requires_action, finish it in the browser with Stripe.js's handleNextAction and the clientSecret.

A customer's first method becomes their default, in Stripe too; change it with POST /payment-methods/{id}/set-default. Deleting the default makes the newest remaining method the default. Payment methods have two IDs: id is Spondo Billing's and externalPaymentMethodId is Stripe's, and both start pm_. Paths take Spondo Billing's.

The customer portal

POST /customer-portals returns a link to Stripe's billing portal for one customer, where they can update their payment details and download invoices. Create one when the customer asks for it, and send them straight there: the link expires after a few minutes.

const { url } = await spondo.request("POST", "/customer-portals", {
  body: { externalCustomerId: "acme-legal", returnUrl: "https://documind.example/account" },
});

Bank transfers

For customers who pay by US bank transfer, POST /customers/{id}/funding-instructions returns bank details unique to the customer, from Stripe: a routing and account number to pay into, which Stripe matches to the customer. Calling it again returns the same details. The hosted invoice page shows these details too. Only USD is supported, bank transfers must be enabled in your Stripe account (otherwise the call fails with 422 STRIPE_BANK_TRANSFERS_NOT_ENABLED), and it needs a live Stripe key: with a test key it fails with 422 ORG_TEST_MODE.