Reconciling with system metadata
The metadata keys Factuarea writes on invoices auto-issued from a Stripe subscription cycle, and how to use the metadata filter to pull every invoice of a subscription or of a billing period.
Every document in Factuarea carries a free-form metadata object you can write
whatever you need into. On invoices that Factuarea issues automatically from a
Stripe subscription cycle, the platform also writes a handful of system
keys that tie the invoice back to the charge it came from: which Stripe
invoice, which subscription, which billing period.
Those keys are what makes reconciliation possible without keeping your own mapping table. They have been written for a while; this page is where they get documented.
Scope: subscription cycles. These keys are written by the flow that
auto-issues an invoice for a billed subscription cycle (see
subscription cycles). One-shot
charges auto-invoiced from charge.succeeded do not carry them today — for
those, correlate through the
auto-invoiced charges listing,
which exposes the charge-side identifiers.
The system keys
| Key | What it identifies | Format | Presence |
|---|---|---|---|
stripe_invoice_id | The Stripe invoice of the billed cycle | Stripe id, in_… | Always |
billing_reason | Why Stripe billed that invoice | Stripe's raw billing_reason — in practice subscription_create (first cycle) or subscription_cycle (each renewal), the only two that are auto-invoiced | Always |
stripe_subscription_id | The subscription the cycle belongs to | Stripe id, sub_… | Optional — omitted when Stripe sends no subscription id |
period_start | First day of the billed period | YYYY-MM-DD, UTC | Optional — omitted when the period timestamp is absent |
period_end | End of the billed period, verbatim from Stripe's period_end — the exclusive boundary, so for a monthly cycle it is the first day of the next period, not the last day of this one | YYYY-MM-DD, UTC | Optional — omitted when the period timestamp is absent |
Optional keys are not materialised as null or empty: when the value does not apply, the key is not written at all. That is deliberate — a key present with an empty value would look like a correlation that exists but is blank, and any code reading it would have to distinguish "no subscription" from "subscription unknown". Check for the key's presence, not for its value.
These are system keys. Do not write them by hand. They are the correlation
between a Factuarea invoice and a Stripe object, and the reconciliation recipes
below trust them. Writing stripe_invoice_id yourself on an unrelated invoice
makes that invoice show up in a reconciliation it does not belong to, and
nothing will flag it — metadata is free-form by design. Use your own keys
(erp_ref, project_code, …) for your own correlations.
The keys are readable wherever the invoice is: metadata is part of the invoice
resource, and it comes back as a JSON object ({} when empty).
Filtering by metadata
Eight v1 listings accept a metadata filter:
| Resource | Endpoint |
|---|---|
| Invoices | GET /v1/invoices |
| Quotes | GET /v1/quotes |
| Pro-forma invoices | GET /v1/proformas |
| Delivery notes | GET /v1/delivery_notes |
| Purchase invoices | GET /v1/purchase_invoices |
| Recurring invoices | GET /v1/recurring_invoices |
| Products | GET /v1/products |
| Suppliers | GET /v1/suppliers |
The syntax is deepObject: metadata[key]=value, one query parameter per pair.
- Pairs combine with AND. Two pairs return the documents that match both.
- Exact match on the value; there is no partial or prefix matching.
- Up to 50 pairs per request; more returns
parameter_invalid_range. - Keys must match
[A-Za-z0-9_.-]and be 1 to 64 characters long; anything else returnsparameter_invalid_enum. - The filter sits outside the
{operator, value}contract of the column filters, so there is nometadata[key][eq]form.metadata[key]=valueis the whole syntax.
Let curl encode the brackets. [ and ] are glob characters for curl and
reserved characters in a URL. Pass the pairs with -G --data-urlencode, as in
the recipes below, and curl encodes them correctly. Pasting a raw
?metadata[key]=value into a shell is where "the filter is being ignored"
usually comes from.
Recipe: every invoice of one subscription
The reconciliation you need when a customer asks for all the invoices of their plan, or when you close the year for one subscriber:
curl -G https://api.factuarea.com/v1/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "metadata[stripe_subscription_id]=sub_1QRstuVWXYZabcde" \
--data-urlencode "limit=100"{
"data": [
{
"id": "0192f3a4-7b2c-7c1d-9e8f-1a2b3c4d5e6f",
"object": "invoice",
"number": "2026/0184",
"total": "49.90",
"currency": "EUR",
"metadata": {
"stripe_invoice_id": "in_1QRstuVWXYZabcde",
"billing_reason": "subscription_cycle",
"stripe_subscription_id": "sub_1QRstuVWXYZabcde",
"period_start": "2026-07-01",
"period_end": "2026-08-01"
}
}
],
"has_more": false,
"next_cursor": null
}The listing is cursor-paginated like every other one: keep reading while
has_more is true, feeding next_cursor back into starting_after. See
pagination.
Recipe: the invoices of one billing period
Two pairs, combined with AND: the subscription and the first day of the period. This is the query that answers "did July's cycle get invoiced?".
curl -G https://api.factuarea.com/v1/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "metadata[stripe_subscription_id]=sub_1QRstuVWXYZabcde" \
--data-urlencode "metadata[period_start]=2026-07-01"Because period_start and period_end are exact dates in UTC, filter on the
period boundary itself rather than on a range — the value in metadata is the day
Stripe reports for the cycle, not a local calendar month. To sweep a whole month
of cycles across every subscription, drop the subscription pair and query
metadata[period_start] alone.
Filter on period_start, not on period_end. period_end is Stripe's
exclusive upper bound: the July cycle of a monthly subscription carries
period_start: 2026-07-01 and period_end: 2026-08-01. Querying
metadata[period_end]=2026-07-31 returns nothing, and the empty result looks
exactly like a cycle that was never invoiced.
An empty data array for a period you expected to be invoiced is a real signal,
not a filter mistake. That is exactly the case the
integration event inbox explains: open it
filtered by provider=stripe and status=skipped and the typed discard reason
will tell you whether the cycle was skipped because auto-invoicing was off, the
cycle carried no amount, or something else — and whether you can replay it.
Related
- Stripe auto-invoicing — how the invoices these keys describe get issued in the first place.
- Integration event inbox — why a cycle you expected never produced an invoice.
- Tags and custom fields — writing and querying your own metadata keys.