Stripe → Solid Accounting
When a customer pays through Stripe, you want the corresponding invoice + payment posted to Solid Accounting automatically — without you copying numbers from the Stripe dashboard. This recipe wires Stripe's webhooks to Solid's REST API for end-to-end automation.
What we're building
Customer pays via Stripe Checkout / Subscription / Payment Link
↓
Stripe sends webhook to your service
↓
Your service POSTs to Solid Accounting's API:
- Invoice (if not already there)
- Payment Application (clears AR)
↓
Solid posts the journal entries with audit trail
The integration handles four Stripe events: charge.succeeded (a payment came in), charge.refunded (a refund went out), payout.paid (Stripe transferred money to your bank), and customer.subscription.updated (subscription metadata changed).
What you'll need
- A Stripe account with access to webhooks
- Solid Accounting Pro or Accountant with the REST API enabled and an API key
- A small service running somewhere that can receive webhooks. Node.js / Python / Go / whatever; the service is ~100 lines of code.
- Reverse-proxy your Solid API to a public hostname so the webhook service can reach it (see API → Network access)
Step 1 — Set up the Stripe webhook
In Stripe Dashboard:
- Developers → Webhooks → Add endpoint
- URL:
https://your-service.example.com/stripe/webhook(your service's endpoint) - Events:
charge.succeeded,charge.refunded,payout.paid,customer.subscription.updated - Copy the signing secret Stripe provides — you'll use it to verify webhooks are real
Step 2 — Build the webhook receiver
Minimal Node.js / Express version:
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
const SOLID_API = "https://your-solid.example.com/api/v1";
const SOLID_API_KEY = process.env.SOLID_API_KEY;
app.post(
"/stripe/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook signature failed: ${err.message}`);
}
try {
switch (event.type) {
case "charge.succeeded":
await handleChargeSucceeded(event.data.object);
break;
case "charge.refunded":
await handleChargeRefunded(event.data.object);
break;
case "payout.paid":
await handlePayoutPaid(event.data.object);
break;
// customer.subscription.updated handled separately
}
res.json({ received: true });
} catch (err) {
console.error("Webhook handler failed:", err);
res.status(500).send("Internal error — Stripe will retry");
}
}
);
app.listen(3000);The express.raw body parser is essential — Stripe's signature verification requires the original byte body.
Step 3 — Handle charge.succeeded
A charge is a customer paying. We post both the invoice (if Solid doesn't have it yet) and the payment that clears it.
async function handleChargeSucceeded(charge) {
const customerId = await ensureCustomer(charge);
const invoiceId = await ensureInvoice(charge, customerId);
await postPayment(charge, invoiceId);
}
async function ensureCustomer(charge) {
// Use Stripe's customer ID as the external_id on the Solid contact.
const externalId = charge.customer || `stripe_anon_${charge.billing_details.email}`;
// Check if Solid already has this contact
const existing = await solidGet(`/contacts?external_id=${encodeURIComponent(externalId)}`);
if (existing.data?.length > 0) return existing.data[0].id;
// Create
const created = await solidPost("/contacts", {
contact_type: "customer",
display_name: charge.billing_details.name || charge.billing_details.email || "Stripe Customer",
email: charge.billing_details.email,
external_id: externalId,
currency_code: charge.currency.toUpperCase(),
});
return created.data.id;
}
async function ensureInvoice(charge, customerId) {
const externalId = `stripe_charge_${charge.id}`;
const existing = await solidGet(`/invoices?external_id=${encodeURIComponent(externalId)}`);
if (existing.data?.length > 0) return existing.data[0].id;
// Map charge → invoice
const lineDescription = charge.description ||
(charge.invoice ? `Stripe invoice ${charge.invoice}` : `Stripe charge ${charge.id}`);
const created = await solidPost("/invoices", {
customer_id: customerId,
date: new Date(charge.created * 1000).toISOString().split("T")[0],
external_id: externalId,
currency_code: charge.currency.toUpperCase(),
lines: [
{
description: lineDescription,
amount_cents: charge.amount,
// Account ID for the income account this revenue posts to —
// configured per integration. See ENV.STRIPE_REVENUE_ACCOUNT_ID
account_id: process.env.STRIPE_REVENUE_ACCOUNT_ID,
},
],
});
return created.data.id;
}
async function postPayment(charge, invoiceId) {
await solidPost("/payments/apply", {
amount_cents: charge.amount,
date: new Date(charge.created * 1000).toISOString().split("T")[0],
bank_account_id: process.env.STRIPE_PENDING_ACCOUNT_ID, // Undeposited Funds
applications: [
{ invoice_id: invoiceId, amount_cents: charge.amount },
],
external_id: `stripe_payment_${charge.id}`,
});
}Note: payments post to a Stripe Pending asset account (set up in Solid as a sub-account of cash), not directly to your bank. When Stripe pays out (next event), we move the balance from Stripe Pending to the actual bank account.
Step 4 — Handle charge.refunded
A refund posts a credit memo against the original invoice.
async function handleChargeRefunded(charge) {
const refund = charge.refunds.data[0]; // most recent refund
const externalId = `stripe_refund_${refund.id}`;
const existing = await solidGet(`/credit-memos?external_id=${encodeURIComponent(externalId)}`);
if (existing.data?.length > 0) return;
// Find the original invoice by Stripe's charge external_id
const origInvoice = await solidGet(`/invoices?external_id=stripe_charge_${charge.id}`);
if (!origInvoice.data?.length) {
console.warn(`Refund for unknown charge ${charge.id} — skipping`);
return;
}
await solidPost("/credit-memos", {
customer_id: origInvoice.data[0].customer_id,
related_invoice_id: origInvoice.data[0].id,
date: new Date(refund.created * 1000).toISOString().split("T")[0],
amount_cents: refund.amount,
external_id: externalId,
currency_code: refund.currency.toUpperCase(),
});
}Step 5 — Handle payout.paid
When Stripe pays out to your bank, Solid needs a transfer from Stripe Pending to Operating Checking.
async function handlePayoutPaid(payout) {
const externalId = `stripe_payout_${payout.id}`;
const existing = await solidGet(`/transfers?external_id=${encodeURIComponent(externalId)}`);
if (existing.data?.length > 0) return;
await solidPost("/transfers", {
date: new Date(payout.arrival_date * 1000).toISOString().split("T")[0],
from_account_id: process.env.STRIPE_PENDING_ACCOUNT_ID,
to_account_id: process.env.OPERATING_BANK_ACCOUNT_ID,
amount_cents: payout.amount,
external_id: externalId,
currency_code: payout.currency.toUpperCase(),
});
}Step 6 — The HTTP helper
async function solidGet(path) {
const res = await fetch(`${SOLID_API}${path}`, {
headers: { "X-API-Key": SOLID_API_KEY },
});
if (!res.ok) throw new Error(`Solid GET ${path} → ${res.status}`);
return res.json();
}
async function solidPost(path, body) {
const res = await fetch(`${SOLID_API}${path}`, {
method: "POST",
headers: {
"X-API-Key": SOLID_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok || json.success === false) {
throw new Error(`Solid POST ${path} → ${res.status} · ${json.error?.message}`);
}
return json;
}Step 7 — Configure account IDs
Look up the UUIDs of the accounts you'll reference:
# Run once to get account IDs
curl -H "X-API-Key: $SOLID_API_KEY" \
https://your-solid.example.com/api/v1/accounts | jq '.data[] | {id, name}'Pick the relevant accounts from the output and set them as environment variables:
STRIPE_PENDING_ACCOUNT_ID=... # The "Stripe Pending" asset account
STRIPE_REVENUE_ACCOUNT_ID=... # The income account for Stripe-charged revenue
OPERATING_BANK_ACCOUNT_ID=... # The bank account Stripe pays out to
If those accounts don't exist yet, create them first via Lists → Chart of Accounts → New.
Step 8 — Test
In Stripe Dashboard, send a test event:
- Developers → Webhooks → click your endpoint → Send test webhook
- Pick
charge.succeeded - Watch your service logs — should show successful POSTs to Solid
- In Solid: check that the customer, invoice, and payment all exist
After a successful test, run a real charge through Stripe and verify the same flow.
Production checklist
- HTTPS-only on your service's webhook URL (Stripe requires it for production)
- Stripe webhook signature verification enabled and
STRIPE_WEBHOOK_SECRETis set - Service has retry logic — return 5xx on transient errors so Stripe retries with exponential backoff
- Idempotency via
external_idon every Solid create - Logging to a place you'll see (CloudWatch, Datadog, etc.) — webhook failures are silent otherwise
- Solid API key stored in your secret store, not committed to git
- Reverse proxy in front of Solid with TLS termination
- Monitor: a graph of "events received" vs. "events processed successfully" — if they diverge, you've got problems
Variations
Subscription billing
For Stripe Subscriptions specifically, listen for invoice.payment_succeeded instead of charge.succeeded. The payload includes the subscription ID and line items, which you can map to recurring revenue accounts.
Multiple revenue accounts based on product
If you sell multiple products through Stripe, look at charge.metadata.product_id (or the product_id on subscription line items) and map to different STRIPE_REVENUE_ACCOUNT_ID values per product.
Stripe fees
Stripe takes a cut of each charge before paying out. The cleanest accounting:
- The invoice posts at the gross amount (what the customer paid)
- The payout posts the net amount to your bank
- Post a periodic Bank Service Charges entry for the difference
Or post the fees per-charge if you have the data and want them visible per-customer.
Cross-references
- REST API → Endpoint groups — full list of endpoints used
- Banking → Undeposited Funds — the pattern Stripe Pending follows
- Accounts Receivable — invoice and payment mechanics in Solid