Full-stack software engineer building useful tools. Constantly learning, with a habit of connecting dots across different domains.
A simple guide to connecting FastSpring to your product, so purchases automatically grant access and renewals, lapses, and cancellations keep that access in sync. It covers one-time products, subscriptions, add-ons, and bundles.
FastSpring is a merchant of record: it runs the checkout and handles payments and tax for you, so you write no payment code. Your side of the integration is just two things:
That’s the whole shape of it. The rest of this post walks through each piece, with framework-agnostic pseudocode you can translate into whatever stack you run.
In the FastSpring dashboard, open Catalog and create your products. There are two unit types:
On each product or subscription, add custom attributes that map it to the matching thing in your system. For example:
Name these to match your own system; the only rule is that every product carries its own values, so your endpoint knows exactly what to provision. A checkout with several products just works as each line item is handled on its own.
In FastSpring, go to Developer Tools → Webhooks → Add Webhook, then:
This is the whole integration: map each FastSpring event to an action in your product.
Every FastSpring request is signed. Before trusting anything in the body, verify the signature: compute an HMAC-SHA256 of the raw request body using your shared secret, base64-encode it, and compare it to the x-fs-signature header. Do this over the raw bytes before you parse JSON — re-serializing the parsed body can change it and break the comparison.
on POST /webhook (request):
secret = env.FASTSPRING_WEBHOOK_SECRET
signature = request.header("x-fs-signature")
if signature is missing:
return 400 "missing signature"
rawBody = request.rawText()
expected = base64(hmac_sha256(rawBody, secret))
if expected != signature:
return 400 "signature mismatch — request may be tampered"
Read body.events[0] (FastSpring wraps the payload in an events array), dispatch on event.type, and on any unknown event or failure return a 4xx with a clear message so it shows up in FastSpring’s webhook log.
The four handlers all follow the same shape — check the event is in the expected state, find or create the record, perform one action — so the whole core fits in a table:
Event | Guard | Action
------------------------------+---------------------------------------+------------------------------------------------------------------------------------
order.completed | order.completed == true and has items | upsert customer → read mapping attributes → grant, storing the FastSpring reference
subscription.charge.completed | charge.status == "successful" | find grant by subscription id → extend; lift suspension if it was suspended
subscription.payment.overdue | state == "overdue" | find grant by subscription id → suspend
subscription.deactivated | state == "deactivated" | find grant by subscription id → revoke
The only handler with real branching is order.completed, because what it stores depends on whether the item is one-time, a subscription, an add-on, or a bundle. That’s the rest of this post. The lifecycle handlers (extend / suspend / revoke) all start the same way — locate the grant by the subscription id you saved at purchase time:
function findGrantsBySubscriptionId(subscriptionId):
matches = yourSystem.findGrants(fastspring_subscription_id == subscriptionId)
if matches.count == 0:
throw "no grant found for " + subscriptionId
return matches // base plan + any add-ons, all extended/suspended/revoked together
A subscription is the interesting case because it has a lifecycle, and each stage is a different webhook. The identifier that ties the whole lifecycle together is subscription.id — store it once at purchase (fastspring_subscription_id), then every later event uses it to find the same grant.
purchase ──────────────▶ order.completed grant access
│ (+ subscription.activated)
▼
renews every period ───▶ subscription.charge.completed extend access
│
├── payment fails ──▶ subscription.payment.overdue suspend access
│ │
│ └── pays ──▶ subscription.charge.completed extend + un-suspend
│
└── ends for good ─▶ subscription.deactivated revoke access
Two things trip people up:
So the order handler decides between one-time and subscription purely by whether the item carries a subscription:
item = order.items[0]
(productId, planId) = readMappingAttributes(item) // product_id / plan_id from Step 1
if item.subscription exists:
reference = ("fastspring_subscription_id", item.subscription.id)
else:
reference = ("fastspring_order_id", order.id)
grantAccess(customerId, productId, planId, reference)
readMappingAttributes just pulls product_id / plan_id off the item’s custom attributes and throws if a product wasn’t configured. upsertCustomer looks the buyer up by email and only creates a new record if none exists, so repeat buyers aren’t duplicated.
A subscription can carry one or more add-ons — think “extra seats” or “premium support” bolted onto a base plan. The rules:
Because they ride the parent, an add-on grant has to remember which subscription drives it. Store the parent’s fastspring_subscription_id on the add-on grant, and tag it fastspring_driver: addon_<parent_product> so you can tell it apart from a grant bought on its own.
That tag is what makes the lifecycle work for free: when subscription.charge.completed, payment.overdue, or deactivated fires for the parent, findGrantBySubscriptionId returns every grant carrying that subscription id — the base plan and its add-ons — so they all extend, suspend, and revoke together. No add-on-specific handler needed.
When the order arrives, the add-on lines reference their parent subscription. Webhook expansion (Step 2) puts the parent’s details inline, so you can resolve and tag each add-on without an extra API call:
for each addon in order add-on items:
(productId, planId) = readMappingAttributes(addon)
grantAccess(customerId, productId, planId,
reference = ("fastspring_subscription_id", parent.subscription.id),
driver = "addon_" + parent.product)
A bundle is one storefront product that contains several. The customer pays once. State the rule that never changes first: a bundle is always a one-time purchase, even if it contains subscriptions — the products inside don’t renew on their own, buying the “Studio Suite” bundle doesn’t create three independent subscriptions that each bill later. So a bundle is keyed by the order, never by a subscription, and there are no renewal / overdue / deactivation events to handle for it.
What does change is whether you expand it — and that’s not really a property of the bundle, it’s whether FastSpring’s single bundle SKU lines up with how entitlements work in your own system.
Two cases:
You already have a SKU that grants everything. If your catalog has a real “D” that turns on all of A, B, and C’s features, there’s no mismatch. Map the bundle product straight to that entitlement with the normal product_id attribute, don’t set is_bundle, and you’re done — the one-time path above already handles it as a single grant. One row to revoke later. The cost is upkeep: every time a component gains a feature, D has to gain it too, or the bundle drifts out of sync with its parts.
You only have the components. If “D” exists only on the storefront and nothing in your system grants A+B+C at once, you expand the bundle into three separate grants. Mark the bundle product with is_bundle: true so your endpoint knows to expand instead of granting it directly, then grant each contained item, tagging each with the fastspring_order_id and fastspring_driver: bundle_<bundle_product>. That driver tag is what lets you treat the three as one unit — it’s how you revoke them together on a refund, since none of them is keyed to a subscription that would lapse on its own.
for each item in order.items:
if item.attributes.is_bundle == true:
continue // skip the bundle product itself
if item.driver.type == "bundle":
grantAccess(..., reference = ("fastspring_order_id", order.id),
driver = "bundle_" + item.driver.path)
else:
... grant single item ...
Both cases run through this same loop; the only difference is the flag. With is_bundle unset, the unified “D” product falls through to the single-grant branch. With it set, the contained items get expanded and tagged. So the heuristic is just: is there already a natural “everything” SKU in your catalog? Grant it directly. Does the bundle exist only on the storefront? Expand it.
A subtlety worth stating regardless of which case you’re in: even when a bundle contains something subscription-like, you grant it as part of the one-time order, you do not wire it into the subscription lifecycle above. Bundles and subscriptions are two different keying strategies (fastspring_order_id vs fastspring_subscription_id); pick per item based on how that item actually bills.
Once your catalog is mapped and your endpoint handles those four events, FastSpring runs checkout, payments, and tax, and your product simply reacts. For anything beyond these cases, FastSpring’s developer docs go deeper.