From 5f245b0c9f490f70d2eb1b94da9343ab2b65c575 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Mon, 27 Apr 2026 12:47:15 +0700 Subject: [PATCH 1/4] docs: add webhook fan-out reference + metadata correlation guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to help external developers self-serve on common integration questions: 1. webhooks/index.mdx — new "Event Trigger Sources" section listing the models whose post_save fires each *.updated event (so subscribers can pick the leanest event for their use case), plus an "Identifying Rebill Orders" section covering the order subscriptions block and the transaction.subscription.billing_cycle counter. 2. admin-api/guides/metadata-and-correlation.mdx — new guide enumerating the 9 metadata-supported object types, with a callout that subscriptions/carts aren't directly metadata-managed. Walks through the canonical attribution.metadata pattern for cross-rebill external ID correlation (set once on subscription attribution, flows through subscription/order/transaction webhooks via FK inheritance with no follow-up API call). Includes a worked plan_id example and a "When to Use Which Surface" decision table. Surfaced via support inquiry from a merchant building integration to an external plan-management service. The fan-out + correlation patterns were institutional knowledge that took multiple rounds to produce authoritatively; this PR puts both into the public docs so future developers get the answer on first read. Co-Authored-By: Claude Opus 4.7 --- content/docs/admin-api/guides/meta.json | 1 + .../guides/metadata-and-correlation.mdx | 132 ++++++++++++++++++ content/docs/webhooks/index.mdx | 53 +++++++ 3 files changed, 186 insertions(+) create mode 100644 content/docs/admin-api/guides/metadata-and-correlation.mdx diff --git a/content/docs/admin-api/guides/meta.json b/content/docs/admin-api/guides/meta.json index 08916472..e41de839 100644 --- a/content/docs/admin-api/guides/meta.json +++ b/content/docs/admin-api/guides/meta.json @@ -4,6 +4,7 @@ "external-checkout", "order-management", "subscription-management", + "metadata-and-correlation", "exports", "testing-guide", "iframe-payment-form", diff --git a/content/docs/admin-api/guides/metadata-and-correlation.mdx b/content/docs/admin-api/guides/metadata-and-correlation.mdx new file mode 100644 index 00000000..12dd8f92 --- /dev/null +++ b/content/docs/admin-api/guides/metadata-and-correlation.mdx @@ -0,0 +1,132 @@ +--- +title: Metadata & External Correlation +sidebar_label: Metadata & External Correlation +sidebar_position: 2 +tags: + - Guide +--- +import { Callout } from 'fumadocs-ui/components/callout'; + +Use metadata to attach custom fields to commerce objects and to correlate events with external systems (CRMs, fulfillment partners, affiliate networks, healthcare platforms, plan-management services, etc.). This guide covers which objects support custom metadata, how to read it back through the API and webhooks, and the canonical pattern for correlating an external identifier across a customer's full subscription lifecycle. + +## Supported Objects + +The Metadata API lets you define custom fields ("definitions") on a fixed set of object types. The supported types are: + +- `attribution` — Marketing attribution +- `customer` — Customers +- `dispute` — Disputes +- `line` — Order line items +- `order` — Orders +- `product` — Products +- `variant` — Product variants +- `blog` — Blog posts +- `page` — CMS pages + +Define fields via [metadataCreate](/docs/admin-api/reference/metadata/metadataCreate). Each definition specifies the `object` type (from the list above), a `key`, a `name`, and a data type. Once defined, the field appears on the corresponding API responses and webhook payloads under that object's `metadata` block. + + +**Subscriptions, carts, and several other resources are not directly metadata-managed.** If you need to attach a custom field to a subscription (for example, an external `plan_id` that should travel with every rebill), use **`attribution.metadata`** instead — see [Cross-Rebill Correlation via Attribution](#cross-rebill-correlation-via-attribution) below. + + +## Setting and Reading Metadata + +After defining a field on a supported object type, set values on individual records via the resource's standard update endpoint. For example, to set order metadata: + +```json title="Set Order Metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/orders/{number}/" +{ + "metadata": { + "external_order_id": "ext_abc123", + "fulfillment_priority": "rush" + } +} +``` + +The values come back in: +- The corresponding GET endpoint (`GET /orders/{number}/`) +- The relevant `*.created` and `*.updated` webhooks for that object + +Field validation (data type, allowed values) is enforced based on the definitions you registered. + +## Cross-Rebill Correlation via Attribution + +A common integration pattern is: **store an external system's identifier on a customer's subscription once, and receive it inline on every charge event for the lifetime of that subscription** — without follow-up API calls. + +For example: a merchant integrating with an external plan-management service (subscription plans, healthcare plans, affiliate plans, etc.) wants the `plan_id` to travel with every rebill order and every capture transaction so their fulfillment service can correlate back to the external system. + +The pattern: store the identifier on the **subscription's marketing attribution metadata**. Every renewal order created by that subscription inherits the same attribution record, so `attribution.metadata` flows through automatically. + +### Step 1 — Define an attribution metadata field + +Create a metadata definition with `object: attribution` to register the field shape: + +```json title="Define attribution metadata field" http-method="POST" http-target="https://{store}.29next.store/api/admin/metadata/" +{ + "object": "attribution", + "key": "plan_id", + "name": "Plan ID", + "type": "string" +} +``` + +### Step 2 — Set the value on the subscription's attribution + +Patch the subscription with attribution metadata: + +```json title="Set subscription attribution metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/subscriptions/{id}/" +{ + "attribution": { + "metadata": { + "plan_id": "plan_abc123" + } + } +} +``` + +### Step 3 — Receive it inline on every charge event + +The same `attribution.metadata` object is included in: + +- `subscription.updated` payloads +- `order.created` payloads — including every renewal order, because rebill order placement reuses the subscription's attribution record +- `transaction.created` payloads — for both initial captures and rebill captures + +```json title="Excerpt of a transaction.created payload" +{ + "event_type": "transaction.created", + "data": { + "type": "debit", + "amount": "29.99", + "subscription": { + "id": 12345, + "billing_cycle": 3 + }, + "attribution": { + "metadata": { + "plan_id": "plan_abc123" + }, + "utm_source": "...", + "utm_campaign": "..." + } + } +} +``` + +Set the value once when the subscription is created (or when the external system first issues an identifier), and your webhook receiver can read it back from `data.attribution.metadata` on every subsequent charge. + + +**Why attribution and not a different field?** `attribution.metadata` is the only metadata surface that (a) lives on the subscription, (b) is inherited by every renewal order automatically, and (c) is included inline on `transaction.created` payloads. Other approaches require either per-order writes (storing on `order.metadata` for each rebill) or a follow-up API call (storing on `customer.metadata` and fetching on every charge). + + +## When to Use Which Surface + +| Need | Use | +|------|-----| +| Custom field on a single order (e.g., custom routing, gift message) | `order.metadata` | +| Custom field on a customer record, viewable in customer webhooks | `customer.metadata` | +| External ID that should travel with every charge for a subscription | `attribution.metadata` on the subscription | +| Per-line-item custom fields (e.g., engraving text, dosage instructions) | `line.metadata` | +| Custom catalog data (e.g., supplier SKU, regulatory codes) | `product.metadata` or `variant.metadata` | +| Dispute case notes / external reference | `dispute.metadata` | + +For data that doesn't fit any of these — for example, cart-level data or session-level data — you'll need to handle it in your application layer; those resources are not part of the public Metadata API surface. diff --git a/content/docs/webhooks/index.mdx b/content/docs/webhooks/index.mdx index b0ecbbd4..153f8c50 100644 --- a/content/docs/webhooks/index.mdx +++ b/content/docs/webhooks/index.mdx @@ -71,6 +71,59 @@ Returning a `410` response code indicates the target resource is no longer avail | `ticket.created` | Triggers a new support ticket is created. | [View Example](/docs/webhooks/reference/support/ticket.created) | | `ticket.updated` | Triggers when an existing support ticket is updated. | [View Example](/docs/webhooks/reference/support/ticket.updated) | +### Event Trigger Sources + +Most `*.created` events fire once per resource creation, but `*.updated` events typically trigger on changes to **multiple related models** — not just the headline resource. Subscribing to a noisy `*.updated` event without understanding the fan-out can produce unexpected event volume. + +The table below lists every model whose change fires each event. Use this when picking the leanest event for your use case — for example, if you only care about payment captures, `transaction.created` filtered to `type: "debit"` fires once per capture, whereas `order.updated` fires on any line, transaction, fulfillment, address, or tag change. + +| Event | Fires when these models change | +| ----- | ------------------------------ | +| `order.created` | `Order` (on creation) | +| `order.updated` | `Order`, `Line`, `Transaction`, `Refund`, `MarketingAttribution`, `ShippingAddress`, `BillingAddress`, `Fulfillment`, plus order tag changes | +| `transaction.created` | `Transaction` (on creation) | +| `transaction.updated` | `Transaction` (on update) | +| `subscription.created` | `Subscription` (on creation) | +| `subscription.updated` | `Subscription`, `SubscriptionLine`, `MarketingAttribution`, `SubscriptionRebillInfo`, `UserAddress` | +| `customer.created` | `User` (on creation) | +| `customer.updated` | `User`, `UserAddress`, plus user tag changes | +| `fulfillment.created` | `Fulfillment` (on creation) | +| `fulfillment.updated` | `Fulfillment`, `FulfillmentLine`, `Tracking` | +| `dispute.created` | `Dispute` (on creation) | +| `dispute.updated` | `Dispute`, related `Transaction` | +| `ticket.created` | `Ticket` (on creation) | +| `ticket.updated` | `Ticket`, `TicketComment`, plus ticket tag changes | +| `product.created` | `Product` (on creation) | +| `product.updated` | `Product`, `ProductImage`, `ProductAttribute`, `Category`, `ProductCategory`, `ProductPrice`, `StockRecord`, `AttributeOption*`, `Option` | +| `gateway.created` / `gateway.updated` | `PaymentGateway`, `PaymentGatewayGroup` | +| `gateway_group.created` / `gateway_group.updated` | `GatewayGroup`, `PaymentGateway` | +| `store.updated` | `SpreedlyConfig`, `AdditionalLanguage`, `Currency`, `Domain`, plus store config changes | + + +**Picking the right event for fulfillment-on-charge.** A common pattern is "do something when a customer is charged, including on subscription rebills." The leanest signal is `transaction.created` filtered to `data.type == "debit"` — this fires once per capture and avoids the broader fan-out of `order.updated`. + + +### Identifying Rebill Orders + +Renewal orders fire `order.created` like any other order. To distinguish a rebill from a new-customer order, check the order payload's `subscriptions` block (and the `subscription_lifecycle` field on the order) — both are populated only on renewal orders. + +For payment events, `transaction.created` payloads include a `subscription` block with the subscription `id` and the `billing_cycle` counter: + +```json title="Subscription block on transaction.created" +"subscription": { + "id": 12345, + "billing_cycle": 2 +} +``` + +`billing_cycle` is the integer cycle number for that subscription's charge: +- `0` — initial purchase +- `1` — first rebill +- `2` — second rebill +- ...and so on + +This is the canonical way to tell, from a charge event alone, which rebill cycle the customer is on. + ### Webhook Data Structure Webhook payloads follow the same structure as Admin API data serializers, which makes them predictable. In general, the data in a webhook payload matches the data you would get by retrieving the same resource through the API. You can set up test webhooks and view the webhook logs in the dashboard to help build and verify your receiver. From 6ecbce233dfca16104093b9a8db2dbe8e69b955e Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 15 Jul 2026 11:57:17 +0200 Subject: [PATCH 2/4] docs: focus subscription charge webhook guidance Replace brittle implementation details with a task-oriented, retry-safe flow for detecting and correlating successful subscription charges. Co-authored-by: Cursor --- content/docs/admin-api/guides/meta.json | 2 +- .../guides/metadata-and-correlation.mdx | 132 ------------------ .../guides/subscription-charge-webhooks.mdx | 118 ++++++++++++++++ content/docs/webhooks/index.mdx | 53 +------ 4 files changed, 121 insertions(+), 184 deletions(-) delete mode 100644 content/docs/admin-api/guides/metadata-and-correlation.mdx create mode 100644 content/docs/admin-api/guides/subscription-charge-webhooks.mdx diff --git a/content/docs/admin-api/guides/meta.json b/content/docs/admin-api/guides/meta.json index e41de839..555ea5bd 100644 --- a/content/docs/admin-api/guides/meta.json +++ b/content/docs/admin-api/guides/meta.json @@ -4,7 +4,7 @@ "external-checkout", "order-management", "subscription-management", - "metadata-and-correlation", + "subscription-charge-webhooks", "exports", "testing-guide", "iframe-payment-form", diff --git a/content/docs/admin-api/guides/metadata-and-correlation.mdx b/content/docs/admin-api/guides/metadata-and-correlation.mdx deleted file mode 100644 index 12dd8f92..00000000 --- a/content/docs/admin-api/guides/metadata-and-correlation.mdx +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Metadata & External Correlation -sidebar_label: Metadata & External Correlation -sidebar_position: 2 -tags: - - Guide ---- -import { Callout } from 'fumadocs-ui/components/callout'; - -Use metadata to attach custom fields to commerce objects and to correlate events with external systems (CRMs, fulfillment partners, affiliate networks, healthcare platforms, plan-management services, etc.). This guide covers which objects support custom metadata, how to read it back through the API and webhooks, and the canonical pattern for correlating an external identifier across a customer's full subscription lifecycle. - -## Supported Objects - -The Metadata API lets you define custom fields ("definitions") on a fixed set of object types. The supported types are: - -- `attribution` — Marketing attribution -- `customer` — Customers -- `dispute` — Disputes -- `line` — Order line items -- `order` — Orders -- `product` — Products -- `variant` — Product variants -- `blog` — Blog posts -- `page` — CMS pages - -Define fields via [metadataCreate](/docs/admin-api/reference/metadata/metadataCreate). Each definition specifies the `object` type (from the list above), a `key`, a `name`, and a data type. Once defined, the field appears on the corresponding API responses and webhook payloads under that object's `metadata` block. - - -**Subscriptions, carts, and several other resources are not directly metadata-managed.** If you need to attach a custom field to a subscription (for example, an external `plan_id` that should travel with every rebill), use **`attribution.metadata`** instead — see [Cross-Rebill Correlation via Attribution](#cross-rebill-correlation-via-attribution) below. - - -## Setting and Reading Metadata - -After defining a field on a supported object type, set values on individual records via the resource's standard update endpoint. For example, to set order metadata: - -```json title="Set Order Metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/orders/{number}/" -{ - "metadata": { - "external_order_id": "ext_abc123", - "fulfillment_priority": "rush" - } -} -``` - -The values come back in: -- The corresponding GET endpoint (`GET /orders/{number}/`) -- The relevant `*.created` and `*.updated` webhooks for that object - -Field validation (data type, allowed values) is enforced based on the definitions you registered. - -## Cross-Rebill Correlation via Attribution - -A common integration pattern is: **store an external system's identifier on a customer's subscription once, and receive it inline on every charge event for the lifetime of that subscription** — without follow-up API calls. - -For example: a merchant integrating with an external plan-management service (subscription plans, healthcare plans, affiliate plans, etc.) wants the `plan_id` to travel with every rebill order and every capture transaction so their fulfillment service can correlate back to the external system. - -The pattern: store the identifier on the **subscription's marketing attribution metadata**. Every renewal order created by that subscription inherits the same attribution record, so `attribution.metadata` flows through automatically. - -### Step 1 — Define an attribution metadata field - -Create a metadata definition with `object: attribution` to register the field shape: - -```json title="Define attribution metadata field" http-method="POST" http-target="https://{store}.29next.store/api/admin/metadata/" -{ - "object": "attribution", - "key": "plan_id", - "name": "Plan ID", - "type": "string" -} -``` - -### Step 2 — Set the value on the subscription's attribution - -Patch the subscription with attribution metadata: - -```json title="Set subscription attribution metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/subscriptions/{id}/" -{ - "attribution": { - "metadata": { - "plan_id": "plan_abc123" - } - } -} -``` - -### Step 3 — Receive it inline on every charge event - -The same `attribution.metadata` object is included in: - -- `subscription.updated` payloads -- `order.created` payloads — including every renewal order, because rebill order placement reuses the subscription's attribution record -- `transaction.created` payloads — for both initial captures and rebill captures - -```json title="Excerpt of a transaction.created payload" -{ - "event_type": "transaction.created", - "data": { - "type": "debit", - "amount": "29.99", - "subscription": { - "id": 12345, - "billing_cycle": 3 - }, - "attribution": { - "metadata": { - "plan_id": "plan_abc123" - }, - "utm_source": "...", - "utm_campaign": "..." - } - } -} -``` - -Set the value once when the subscription is created (or when the external system first issues an identifier), and your webhook receiver can read it back from `data.attribution.metadata` on every subsequent charge. - - -**Why attribution and not a different field?** `attribution.metadata` is the only metadata surface that (a) lives on the subscription, (b) is inherited by every renewal order automatically, and (c) is included inline on `transaction.created` payloads. Other approaches require either per-order writes (storing on `order.metadata` for each rebill) or a follow-up API call (storing on `customer.metadata` and fetching on every charge). - - -## When to Use Which Surface - -| Need | Use | -|------|-----| -| Custom field on a single order (e.g., custom routing, gift message) | `order.metadata` | -| Custom field on a customer record, viewable in customer webhooks | `customer.metadata` | -| External ID that should travel with every charge for a subscription | `attribution.metadata` on the subscription | -| Per-line-item custom fields (e.g., engraving text, dosage instructions) | `line.metadata` | -| Custom catalog data (e.g., supplier SKU, regulatory codes) | `product.metadata` or `variant.metadata` | -| Dispute case notes / external reference | `dispute.metadata` | - -For data that doesn't fit any of these — for example, cart-level data or session-level data — you'll need to handle it in your application layer; those resources are not part of the public Metadata API surface. diff --git a/content/docs/admin-api/guides/subscription-charge-webhooks.mdx b/content/docs/admin-api/guides/subscription-charge-webhooks.mdx new file mode 100644 index 00000000..134173a4 --- /dev/null +++ b/content/docs/admin-api/guides/subscription-charge-webhooks.mdx @@ -0,0 +1,118 @@ +--- +title: Handle Subscription Charge Webhooks +sidebar_label: Subscription Charge Webhooks +sidebar_position: 2 +tags: + - Guide +--- +import { Callout } from 'fumadocs-ui/components/callout'; + +Use `transaction.created` webhooks to react to successful subscription charges, distinguish an initial charge from a renewal, and correlate the charge with a record in an external system. + +## Detect a Successful Charge + +A `transaction.created` event represents a payment attempt, not necessarily a successful payment. Process the event as a charge only when both of these conditions are true: + +- `data.type` is `debit` +- `data.status` is `succeeded` + +This is more precise than using `order.created`, which indicates that an order was placed, or `order.updated`, which can fire repeatedly as related order data changes. + +```json title="Successful Subscription Charge" +{ + "event_id": "f7eb1338-0934-4cda-8128-d6a77761a368", + "event_type": "transaction.created", + "data": { + "id": 10416, + "type": "debit", + "status": "succeeded", + "amount": "29.99", + "order": "116663", + "subscription": { + "id": 12345, + "billing_cycle": 3 + }, + "attribution": { + "metadata": { + "plan_id": "plan_abc123" + } + } + } +} +``` + + +Webhook deliveries are retried when your endpoint does not return `200`. Store the `event_id` or transaction `data.id` before performing side effects so a repeated delivery does not trigger fulfillment or another external action twice. + + +## Identify Initial and Renewal Charges + +For a subscription charge, `data.subscription` contains the subscription ID and its billing-cycle counter: + +- `billing_cycle: 0` — initial subscription charge +- `billing_cycle: 1` — first renewal +- `billing_cycle: 2` — second renewal +- and so on + +A value greater than `0` identifies a renewal. The subscription block is empty for transactions that are not associated with a subscription. + +```javascript title="Filter and Classify a Subscription Charge" +function handleTransactionCreated(payload) { + const transaction = payload.data; + + if (transaction.type !== "debit" || transaction.status !== "succeeded") { + return; + } + + if (!transaction.subscription?.id) { + return; + } + + const isRenewal = transaction.subscription.billing_cycle > 0; + const externalPlanId = transaction.attribution?.metadata?.plan_id; + + // Deduplicate payload.event_id or transaction.id before side effects. + // Process the initial or renewal charge in your external system. +} +``` + +## Correlate Charges with an External System + +If an external identifier should accompany every charge for a subscription, store it in the subscription's attribution metadata. Renewal orders and their transactions include that attribution in webhook payloads, so the receiver does not need a follow-up API request. + +### 1. Define the Metadata Field + +Create an attribution metadata definition once per store. The field type for a string value is `text`. + +```json title="Define Attribution Metadata" http-method="POST" http-target="https://{store}.29next.store/api/admin/metadata/" +{ + "object": "attribution", + "key": "plan_id", + "name": "Plan ID", + "type": "text" +} +``` + +See [metadataCreate](/docs/admin-api/reference/metadata/metadataCreate) for available field types and validation options. + +### 2. Set the Identifier on the Subscription + +Set the value when the subscription is created, or add it later with [subscriptionsPartialUpdate](/docs/admin-api/reference/subscriptions/subscriptionsPartialUpdate). + +```json title="Set Subscription Attribution Metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/subscriptions/{id}/" +{ + "attribution": { + "metadata": { + "plan_id": "plan_abc123" + } + } +} +``` + +### 3. Read the Identifier from the Webhook + +Read the value from `data.attribution.metadata.plan_id` after confirming that the transaction succeeded. This pattern also works for other external identifiers, such as a CRM subscription ID or fulfillment-plan reference. + + +Use customer metadata for values that belong to the customer regardless of subscription. Use order metadata for values that apply to one order only. Attribution metadata is the recommended surface for an identifier that should accompany each charge for a specific subscription. + diff --git a/content/docs/webhooks/index.mdx b/content/docs/webhooks/index.mdx index 153f8c50..7ec1cacf 100644 --- a/content/docs/webhooks/index.mdx +++ b/content/docs/webhooks/index.mdx @@ -71,59 +71,10 @@ Returning a `410` response code indicates the target resource is no longer avail | `ticket.created` | Triggers a new support ticket is created. | [View Example](/docs/webhooks/reference/support/ticket.created) | | `ticket.updated` | Triggers when an existing support ticket is updated. | [View Example](/docs/webhooks/reference/support/ticket.updated) | -### Event Trigger Sources - -Most `*.created` events fire once per resource creation, but `*.updated` events typically trigger on changes to **multiple related models** — not just the headline resource. Subscribing to a noisy `*.updated` event without understanding the fan-out can produce unexpected event volume. - -The table below lists every model whose change fires each event. Use this when picking the leanest event for your use case — for example, if you only care about payment captures, `transaction.created` filtered to `type: "debit"` fires once per capture, whereas `order.updated` fires on any line, transaction, fulfillment, address, or tag change. - -| Event | Fires when these models change | -| ----- | ------------------------------ | -| `order.created` | `Order` (on creation) | -| `order.updated` | `Order`, `Line`, `Transaction`, `Refund`, `MarketingAttribution`, `ShippingAddress`, `BillingAddress`, `Fulfillment`, plus order tag changes | -| `transaction.created` | `Transaction` (on creation) | -| `transaction.updated` | `Transaction` (on update) | -| `subscription.created` | `Subscription` (on creation) | -| `subscription.updated` | `Subscription`, `SubscriptionLine`, `MarketingAttribution`, `SubscriptionRebillInfo`, `UserAddress` | -| `customer.created` | `User` (on creation) | -| `customer.updated` | `User`, `UserAddress`, plus user tag changes | -| `fulfillment.created` | `Fulfillment` (on creation) | -| `fulfillment.updated` | `Fulfillment`, `FulfillmentLine`, `Tracking` | -| `dispute.created` | `Dispute` (on creation) | -| `dispute.updated` | `Dispute`, related `Transaction` | -| `ticket.created` | `Ticket` (on creation) | -| `ticket.updated` | `Ticket`, `TicketComment`, plus ticket tag changes | -| `product.created` | `Product` (on creation) | -| `product.updated` | `Product`, `ProductImage`, `ProductAttribute`, `Category`, `ProductCategory`, `ProductPrice`, `StockRecord`, `AttributeOption*`, `Option` | -| `gateway.created` / `gateway.updated` | `PaymentGateway`, `PaymentGatewayGroup` | -| `gateway_group.created` / `gateway_group.updated` | `GatewayGroup`, `PaymentGateway` | -| `store.updated` | `SpreedlyConfig`, `AdditionalLanguage`, `Currency`, `Domain`, plus store config changes | - - -**Picking the right event for fulfillment-on-charge.** A common pattern is "do something when a customer is charged, including on subscription rebills." The leanest signal is `transaction.created` filtered to `data.type == "debit"` — this fires once per capture and avoids the broader fan-out of `order.updated`. + +To react to a successful subscription charge, use `transaction.created` and check both the transaction type and status. See [Handle Subscription Charge Webhooks](/docs/admin-api/guides/subscription-charge-webhooks) for renewal detection, external-ID correlation, and retry-safe processing. -### Identifying Rebill Orders - -Renewal orders fire `order.created` like any other order. To distinguish a rebill from a new-customer order, check the order payload's `subscriptions` block (and the `subscription_lifecycle` field on the order) — both are populated only on renewal orders. - -For payment events, `transaction.created` payloads include a `subscription` block with the subscription `id` and the `billing_cycle` counter: - -```json title="Subscription block on transaction.created" -"subscription": { - "id": 12345, - "billing_cycle": 2 -} -``` - -`billing_cycle` is the integer cycle number for that subscription's charge: -- `0` — initial purchase -- `1` — first rebill -- `2` — second rebill -- ...and so on - -This is the canonical way to tell, from a charge event alone, which rebill cycle the customer is on. - ### Webhook Data Structure Webhook payloads follow the same structure as Admin API data serializers, which makes them predictable. In general, the data in a webhook payload matches the data you would get by retrieving the same resource through the API. You can set up test webhooks and view the webhook logs in the dashboard to help build and verify your receiver. From 1e50a530939e4f71b4094eda91132d70849f9d68 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 22 Jul 2026 09:16:20 +0700 Subject: [PATCH 3/4] docs: fold subscription charges into management guide --- content/docs/admin-api/guides/meta.json | 1 - .../guides/subscription-charge-webhooks.mdx | 118 ------------------ .../guides/subscription-management.mdx | 27 ++++ content/docs/webhooks/index.mdx | 2 +- 4 files changed, 28 insertions(+), 120 deletions(-) delete mode 100644 content/docs/admin-api/guides/subscription-charge-webhooks.mdx diff --git a/content/docs/admin-api/guides/meta.json b/content/docs/admin-api/guides/meta.json index 555ea5bd..08916472 100644 --- a/content/docs/admin-api/guides/meta.json +++ b/content/docs/admin-api/guides/meta.json @@ -4,7 +4,6 @@ "external-checkout", "order-management", "subscription-management", - "subscription-charge-webhooks", "exports", "testing-guide", "iframe-payment-form", diff --git a/content/docs/admin-api/guides/subscription-charge-webhooks.mdx b/content/docs/admin-api/guides/subscription-charge-webhooks.mdx deleted file mode 100644 index 134173a4..00000000 --- a/content/docs/admin-api/guides/subscription-charge-webhooks.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Handle Subscription Charge Webhooks -sidebar_label: Subscription Charge Webhooks -sidebar_position: 2 -tags: - - Guide ---- -import { Callout } from 'fumadocs-ui/components/callout'; - -Use `transaction.created` webhooks to react to successful subscription charges, distinguish an initial charge from a renewal, and correlate the charge with a record in an external system. - -## Detect a Successful Charge - -A `transaction.created` event represents a payment attempt, not necessarily a successful payment. Process the event as a charge only when both of these conditions are true: - -- `data.type` is `debit` -- `data.status` is `succeeded` - -This is more precise than using `order.created`, which indicates that an order was placed, or `order.updated`, which can fire repeatedly as related order data changes. - -```json title="Successful Subscription Charge" -{ - "event_id": "f7eb1338-0934-4cda-8128-d6a77761a368", - "event_type": "transaction.created", - "data": { - "id": 10416, - "type": "debit", - "status": "succeeded", - "amount": "29.99", - "order": "116663", - "subscription": { - "id": 12345, - "billing_cycle": 3 - }, - "attribution": { - "metadata": { - "plan_id": "plan_abc123" - } - } - } -} -``` - - -Webhook deliveries are retried when your endpoint does not return `200`. Store the `event_id` or transaction `data.id` before performing side effects so a repeated delivery does not trigger fulfillment or another external action twice. - - -## Identify Initial and Renewal Charges - -For a subscription charge, `data.subscription` contains the subscription ID and its billing-cycle counter: - -- `billing_cycle: 0` — initial subscription charge -- `billing_cycle: 1` — first renewal -- `billing_cycle: 2` — second renewal -- and so on - -A value greater than `0` identifies a renewal. The subscription block is empty for transactions that are not associated with a subscription. - -```javascript title="Filter and Classify a Subscription Charge" -function handleTransactionCreated(payload) { - const transaction = payload.data; - - if (transaction.type !== "debit" || transaction.status !== "succeeded") { - return; - } - - if (!transaction.subscription?.id) { - return; - } - - const isRenewal = transaction.subscription.billing_cycle > 0; - const externalPlanId = transaction.attribution?.metadata?.plan_id; - - // Deduplicate payload.event_id or transaction.id before side effects. - // Process the initial or renewal charge in your external system. -} -``` - -## Correlate Charges with an External System - -If an external identifier should accompany every charge for a subscription, store it in the subscription's attribution metadata. Renewal orders and their transactions include that attribution in webhook payloads, so the receiver does not need a follow-up API request. - -### 1. Define the Metadata Field - -Create an attribution metadata definition once per store. The field type for a string value is `text`. - -```json title="Define Attribution Metadata" http-method="POST" http-target="https://{store}.29next.store/api/admin/metadata/" -{ - "object": "attribution", - "key": "plan_id", - "name": "Plan ID", - "type": "text" -} -``` - -See [metadataCreate](/docs/admin-api/reference/metadata/metadataCreate) for available field types and validation options. - -### 2. Set the Identifier on the Subscription - -Set the value when the subscription is created, or add it later with [subscriptionsPartialUpdate](/docs/admin-api/reference/subscriptions/subscriptionsPartialUpdate). - -```json title="Set Subscription Attribution Metadata" http-method="PATCH" http-target="https://{store}.29next.store/api/admin/subscriptions/{id}/" -{ - "attribution": { - "metadata": { - "plan_id": "plan_abc123" - } - } -} -``` - -### 3. Read the Identifier from the Webhook - -Read the value from `data.attribution.metadata.plan_id` after confirming that the transaction succeeded. This pattern also works for other external identifiers, such as a CRM subscription ID or fulfillment-plan reference. - - -Use customer metadata for values that belong to the customer regardless of subscription. Use order metadata for values that apply to one order only. Attribution metadata is the recommended surface for an identifier that should accompany each charge for a specific subscription. - diff --git a/content/docs/admin-api/guides/subscription-management.mdx b/content/docs/admin-api/guides/subscription-management.mdx index cc336057..d7ec26b6 100644 --- a/content/docs/admin-api/guides/subscription-management.mdx +++ b/content/docs/admin-api/guides/subscription-management.mdx @@ -123,6 +123,33 @@ The endpoint returns the full subscription object with updated renewal details o The subscription must be in an `active` status to trigger a renewal. For subscriptions in `past_due` status, use the [subscriptionsRetryCreate](/docs/admin-api/reference/subscriptions/subscriptionsRetryCreate) endpoint instead. +### Identifying Subscription Charges + +Use the `transaction.created` webhook to identify charges associated with a subscription. A successful charge has a `data.type` of `debit` and a `data.status` of `succeeded`. + +When a transaction is associated with a subscription, `data.subscription` contains the subscription ID and billing cycle: + +```json title="Subscription Transaction" +{ + "event_type": "transaction.created", + "data": { + "id": 10416, + "type": "debit", + "status": "succeeded", + "subscription": { + "id": 12345, + "billing_cycle": 3 + } + } +} +``` + +- `billing_cycle: 0` identifies the initial subscription charge. +- `billing_cycle: 1` identifies the first renewal. +- Each subsequent renewal increments the value by one. + +The subscription object is empty when the transaction is not associated with a subscription. + ### Updating Payment Details Updating the Payment Gateway of a subscription can done through the [subscriptionsPartialUpdate](/docs/admin-api/reference/subscriptions/subscriptionsPartialUpdate) endpoint. diff --git a/content/docs/webhooks/index.mdx b/content/docs/webhooks/index.mdx index 7ec1cacf..0dada28e 100644 --- a/content/docs/webhooks/index.mdx +++ b/content/docs/webhooks/index.mdx @@ -72,7 +72,7 @@ Returning a `410` response code indicates the target resource is no longer avail | `ticket.updated` | Triggers when an existing support ticket is updated. | [View Example](/docs/webhooks/reference/support/ticket.updated) | -To react to a successful subscription charge, use `transaction.created` and check both the transaction type and status. See [Handle Subscription Charge Webhooks](/docs/admin-api/guides/subscription-charge-webhooks) for renewal detection, external-ID correlation, and retry-safe processing. +The `subscription` object on a `transaction.created` webhook links the charge to its subscription and identifies the billing cycle. See [Identifying Subscription Charges](/docs/admin-api/guides/subscription-management#identifying-subscription-charges). ### Webhook Data Structure From e082aa49e6400fa2913f928648ca542bd1b8d8a4 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Wed, 22 Jul 2026 09:22:49 +0700 Subject: [PATCH 4/4] docs: narrow subscription charge guidance --- content/docs/admin-api/guides/subscription-management.mdx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/content/docs/admin-api/guides/subscription-management.mdx b/content/docs/admin-api/guides/subscription-management.mdx index d2be57af..4cca8885 100644 --- a/content/docs/admin-api/guides/subscription-management.mdx +++ b/content/docs/admin-api/guides/subscription-management.mdx @@ -125,17 +125,13 @@ The subscription must be in an `active` status to trigger a renewal. For subscri ### Identifying Subscription Charges -Use the `transaction.created` webhook to identify charges associated with a subscription. A successful charge has a `data.type` of `debit` and a `data.status` of `succeeded`. - -When a transaction is associated with a subscription, `data.subscription` contains the subscription ID and billing cycle: +When a `transaction.created` webhook is associated with a subscription, `data.subscription` contains the subscription ID and billing cycle: ```json title="Subscription Transaction" { "event_type": "transaction.created", "data": { "id": 10416, - "type": "debit", - "status": "succeeded", "subscription": { "id": 12345, "billing_cycle": 3