Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
Developer Handover Pack
Subscription & Recurring Billing
[YourCompany.com] · Finance Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything needed to construct, connect, and verify the three-agent Subscription and Recurring Billing automation. It covers the current-state process step map, full agent specifications with IO contracts, an end-to-end data flow trace, and the recommended build stack. The owner-side contacts (Rachel Tan, Owen Fischer, Priya Menon) handle credential provisioning and exception review; FullSpec handles all build, integration, and testing work. Refer any access or permissions questions to support@gofullspec.com.
01Process snapshot
02Agent specifications
Handles subscriber retrieval, invoice creation, and payment charging at the start of each billing cycle. The agent reads all active subscription records from HubSpot, maps each record to the correct Xero account code and tax treatment based on plan tier, generates a Xero invoice for every subscriber, and triggers a Stripe charge against the stored payment method. It captures the charge outcome (success or failure) for every invoice and passes that payload downstream. This agent must complete atomically per subscriber: if invoice creation in Xero fails for a given record, the Stripe charge for that subscriber must not fire. Estimated build time: 14 hours. Complexity: Moderate.
// Input
HubSpot query filter: { lifecyclestage: 'customer', hs_lead_status: 'active', billing_cycle_date: TODAY }
Fields consumed: contact_id, email, first_name, last_name, plan_tier, plan_price_usd, stripe_customer_id, xero_contact_id, renewal_date
// Output
Per subscriber: { subscriber_id, xero_invoice_id, stripe_payment_intent_id, charge_status: 'succeeded'|'failed', amount_usd, currency: 'USD', timestamp_utc }
Batch summary: { total_invoices_created, total_charged, total_failed, billing_run_id }- HubSpot tier must be Sales Hub Starter or above to allow API-level contact querying with custom property filters. Confirm the billing_cycle_date and plan_tier custom properties exist before build; create them if absent.
- Xero plan requires Xero Standard or above for API invoice creation. Confirm account codes per plan tier (e.g. 200 for standard subscriptions, 201 for premium) and tax type identifiers (e.g. OUTPUT, OUTPUT2) before mapping is finalised.
- Stripe charge uses the PaymentIntents API with confirm: true and the subscriber's stored default PaymentMethod. Do not use the legacy Charges API. Confirm that each subscriber record holds a valid stripe_customer_id with a default payment method attached.
- Invoice creation in Xero must precede the Stripe charge attempt in the execution order. Use a sequential per-subscriber loop, not a parallel batch, to enforce this dependency.
- Deduplication: check for an existing Xero invoice with the same xero_contact_id and invoice date before creating a new one. If a draft invoice already exists, update it rather than creating a duplicate.
- Proration is out of scope for the initial build. Fixed monthly pricing only. Flag any subscriber with a non-standard plan_price_usd value for manual review rather than attempting automated proration.
- Confirm with Rachel Tan (Billing Admin) that the HubSpot active subscriber list is clean and that no test or churned contacts remain tagged as active before the first live run.
Monitors Stripe for failed payment events, sends recovery emails via Gmail, schedules retry attempts at a fixed 72-hour interval, and updates subscriber status in HubSpot after each attempt outcome. The agent is event-driven: it activates on receipt of a Stripe payment_intent.payment_failed webhook rather than on a schedule. It maintains a retry counter per subscriber and stops automated retries after two attempts (72 hours and 144 hours post-failure), at which point the subscriber record is flagged in HubSpot for manual escalation and no further automated emails are sent. Estimated build time: 12 hours. Complexity: Moderate.
// Input
Stripe webhook payload: { type: 'payment_intent.payment_failed', data.object: { id: payment_intent_id, customer: stripe_customer_id, amount: int, currency: 'usd', last_payment_error: { code, message } } }
Lookup: HubSpot contact by stripe_customer_id -> { contact_id, email, first_name, plan_tier, retry_count, xero_invoice_id }
// Output (attempt 1 and attempt 2)
Gmail: recovery email sent to subscriber email with card-update link and personalised greeting
Stripe: PaymentIntent retry scheduled at T+72h and T+144h via the automation platform's delay node
HubSpot contact updated: { billing_status: 'payment_failed', retry_count: N, last_retry_attempt_utc: timestamp }
// On max retries exceeded
HubSpot contact updated: { billing_status: 'escalated', retry_count: 2 }
No further automated emails sent; record surfaced in finance team review queue- Stripe webhook endpoint must be registered in the Stripe Dashboard under Developers > Webhooks before build testing begins. The endpoint URL is generated by the automation platform and must be added to the Stripe account's live and test webhook listeners.
- Listen specifically for payment_intent.payment_failed and payment_intent.succeeded events. Do not use the charge.failed event as it does not carry PaymentIntent metadata reliably.
- Gmail send must use a verified sender address configured in the automation platform's Gmail credential. Confirm the sender address (e.g. billing@[YourCompany.com]) with Rachel Tan before build; it must match the domain's SPF and DKIM records.
- The retry counter (retry_count) must be stored as a HubSpot custom numeric property on the contact object. Confirm this property exists or create it. Reset retry_count to 0 on successful payment confirmation from the Reconciliation Agent.
- Retry timing must comply with Stripe's guidelines: do not retry sooner than 24 hours after a failure. The 72-hour interval satisfies this requirement. Confirm with Stripe account settings that automatic retries (Smart Retries) are disabled on the Stripe side to avoid double-retry conflicts.
- If a subscriber's payment succeeds on retry, the Stripe payment_intent.succeeded webhook fires and the Reconciliation and Notification Agent takes over. Ensure the two agents do not both write to the same HubSpot billing_status field simultaneously; use idempotent writes keyed on payment_intent_id.
Matches Stripe payment confirmations to open Xero invoices, marks them as paid, sends payment confirmation emails to successful payers, queues renewal reminder emails for subscribers approaching their renewal date, and posts the billing run summary to the designated Slack finance channel. This agent handles both the initial successful payment path (triggered immediately after the Billing Cycle Agent) and the successful retry path (triggered when the Failed Payment Recovery Agent's retry clears). Unmatched items, where no open Xero invoice can be found for a given Stripe payment, are flagged in a Supabase exceptions table and surfaced to the bookkeeper (Owen Fischer) for manual review. Estimated build time: 12 hours. Complexity: Moderate.
// Input
Stripe webhook payload: { type: 'payment_intent.succeeded', data.object: { id: payment_intent_id, customer: stripe_customer_id, amount_received: int, currency: 'usd' } }
Xero lookup: open invoice where Contact.ContactID = xero_contact_id AND Status = 'AUTHORISED' AND AmountDue = (amount_received / 100)
HubSpot lookup by stripe_customer_id: { contact_id, email, first_name, renewal_date, plan_tier }
// Output
Xero: invoice Status set to 'PAID', payment record created with { Date: today, Amount: amount_received_usd, Account: bank_account_id }
Gmail: confirmation email sent to subscriber email with invoice PDF link and payment amount
Gmail (conditional): renewal reminder email queued if renewal_date is within 30 days
Slack: message posted to #finance-billing channel: { billing_run_id, total_invoices, total_paid_usd, total_failed, retry_successes, unmatched_count }
// On unmatched invoice
Supabase exceptions table insert: { payment_intent_id, stripe_customer_id, amount_usd, error: 'no_matching_xero_invoice', flagged_at_utc: timestamp }
Slack alert posted to #finance-billing: 'Unmatched payment flagged for review: [stripe_customer_id]'- Xero invoice matching must use both xero_contact_id and AmountDue as match criteria to prevent incorrect reconciliation where a subscriber has multiple open invoices. If more than one match is found, write to the exceptions table rather than guessing.
- The Xero payment record must reference the correct Xero bank account ID corresponding to the Stripe settlement account. Confirm this account ID with Owen Fischer (Bookkeeper) before the reconciliation build begins.
- The Slack summary should be posted once per billing run, not once per subscriber payment. Accumulate the run totals in a temporary data store (a Supabase billing_run_summary row keyed on billing_run_id) and post to Slack only when the full run is complete or a configurable timeout (e.g. 4 hours) is reached.
- Renewal reminder emails should only fire if renewal_date is within the next 30 days and the subscriber's billing_status in HubSpot is 'active'. Do not send reminders to subscribers currently in a 'payment_failed' or 'escalated' state.
- Gmail invoice PDF link should point to the Xero invoice online view URL (available from the Xero API response as OnlineInvoiceUrl). Confirm Xero online invoice sharing is enabled for the organisation before referencing this URL in email templates.
- Confirm the target Slack channel name with Priya Menon (Finance Manager). The automation platform credential must have the channels:write and chat:write OAuth scopes for the target workspace.
03End-to-end data flow
// ============================================================
// TRIGGER
// ============================================================
SCHEDULE: cron(0 6 1 * *) // 06:00 UTC on billing period start date
-> billing_run_id = uuid()
-> date_today = ISO8601 date
// ============================================================
// BILLING CYCLE AGENT
// ============================================================
HUBSPOT_QUERY:
endpoint: GET /crm/v3/objects/contacts/search
filter: { lifecyclestage = 'customer', hs_lead_status = 'active', billing_cycle_date = date_today }
fields_returned: [contact_id, email, first_name, last_name, plan_tier,
plan_price_usd, stripe_customer_id, xero_contact_id, renewal_date]
-> subscriber_list[]
FOR EACH subscriber IN subscriber_list:
// Dedupe check
XERO_CHECK:
endpoint: GET /api.xro/2.0/Invoices
filter: ContactID = subscriber.xero_contact_id AND Date = date_today AND Status = 'DRAFT'|'AUTHORISED'
IF existing_invoice FOUND -> UPDATE existing_invoice
ELSE -> CREATE new invoice
XERO_CREATE_INVOICE:
endpoint: POST /api.xro/2.0/Invoices
body: { Type: 'ACCREC', Contact: { ContactID: subscriber.xero_contact_id },
Date: date_today, DueDate: date_today,
LineItems: [{ Description: subscriber.plan_tier, UnitAmount: subscriber.plan_price_usd,
AccountCode: '200'|'201', TaxType: 'OUTPUT' }],
Status: 'AUTHORISED' }
-> xero_invoice_id, xero_invoice_url
STRIPE_CHARGE:
endpoint: POST /v1/payment_intents
body: { amount: subscriber.plan_price_usd * 100, currency: 'usd',
customer: subscriber.stripe_customer_id,
payment_method: default_payment_method,
confirm: true, off_session: true,
metadata: { xero_invoice_id: xero_invoice_id, billing_run_id: billing_run_id } }
-> payment_intent_id, charge_status ('succeeded'|'requires_payment_method'|'failed')
// ============================================================
// DECISION: charge_status
// ============================================================
IF charge_status = 'succeeded':
-> EMIT payment_intent.succeeded -> Reconciliation and Notification Agent
IF charge_status = 'failed' OR 'requires_payment_method':
-> EMIT payment_intent.payment_failed -> Failed Payment Recovery Agent
// ============================================================
// FAILED PAYMENT RECOVERY AGENT
// ============================================================
ON_EVENT: payment_intent.payment_failed
stripe_payload: { payment_intent_id, stripe_customer_id, amount, last_payment_error.code }
HUBSPOT_LOOKUP:
endpoint: GET /crm/v3/objects/contacts?filter=stripe_customer_id={stripe_customer_id}
-> contact_id, email, first_name, plan_tier, retry_count, xero_invoice_id
GMAIL_SEND (attempt 1 email):
to: subscriber.email
subject: 'Action required: payment for your [YourCompany.com] subscription'
body_fields: [first_name, plan_tier, amount_usd, card_update_url]
-> message_id
HUBSPOT_UPDATE:
endpoint: PATCH /crm/v3/objects/contacts/{contact_id}
body: { billing_status: 'payment_failed', retry_count: 1,
last_retry_attempt_utc: timestamp_utc }
DELAY: 72 hours
STRIPE_RETRY:
endpoint: POST /v1/payment_intents/{payment_intent_id}/confirm
-> charge_status
IF charge_status = 'succeeded': -> EMIT payment_intent.succeeded -> Reconciliation Agent
IF charge_status = 'failed' AND retry_count < 2:
GMAIL_SEND (attempt 2 email): same template, updated tone
HUBSPOT_UPDATE: { retry_count: 2 }
DELAY: 72 hours
STRIPE_RETRY (attempt 2) -> charge_status
IF 'succeeded': -> EMIT payment_intent.succeeded -> Reconciliation Agent
IF 'failed': HUBSPOT_UPDATE { billing_status: 'escalated' } // no further automated action
// ============================================================
// RECONCILIATION AND NOTIFICATION AGENT
// ============================================================
ON_EVENT: payment_intent.succeeded
stripe_payload: { payment_intent_id, stripe_customer_id, amount_received, metadata.xero_invoice_id, metadata.billing_run_id }
XERO_MATCH:
endpoint: GET /api.xro/2.0/Invoices/{xero_invoice_id}
validate: Status = 'AUTHORISED' AND AmountDue = amount_received / 100
IF match: PROCEED
IF no match: INSERT exceptions table { payment_intent_id, stripe_customer_id, amount_usd, error: 'no_matching_xero_invoice' }
POST Slack alert -> HALT for this subscriber
XERO_MARK_PAID:
endpoint: PUT /api.xro/2.0/Invoices/{xero_invoice_id}/Payments
body: { Date: date_today, Amount: amount_received_usd, Account: { Code: 'STRIPE-BANK' } }
-> xero_payment_id, xero_invoice_url (OnlineInvoiceUrl)
HUBSPOT_LOOKUP: stripe_customer_id -> { contact_id, email, first_name, renewal_date, billing_status }
GMAIL_SEND (confirmation):
to: subscriber.email
body_fields: [first_name, amount_usd, date_today, xero_invoice_url]
IF renewal_date <= date_today + 30 days AND billing_status = 'active':
GMAIL_SEND (renewal reminder):
to: subscriber.email
body_fields: [first_name, renewal_date, plan_tier]
HUBSPOT_UPDATE: { billing_status: 'active', retry_count: 0 }
SUPABASE_UPSERT billing_run_summary:
{ billing_run_id, total_paid_count++, total_paid_usd += amount_received_usd }
// After all events processed or timeout reached:
SLACK_POST:
channel: '#finance-billing'
fields: [billing_run_id, total_invoices_created, total_paid_usd, total_failed_count,
retry_success_count, unmatched_count]04Recommended build stack
More documents for this process
Every document generated for Subscription & Recurring Billing.