FastSpring Integration: Receiver Script
Two files, Python 3.8 or later, standard library only. They implement the FastSpring Licensing Integration; the setup page shows where they run.
| File | Purpose |
|---|---|
fastspring_netlicensing.py | HTTP receiver for FastSpring webhooks |
fastspring_backfill.py | Processes events that FastSpring still lists as unprocessed, then marks them processed |
products.json | Your map from FastSpring product paths to NetLicensing license template numbers (example) |
Configuration
| Variable | Required | Meaning |
|---|---|---|
NETLICENSING_API_KEY | yes | API key with ROLE_APIKEY_OPERATION |
NETLICENSING_PRODUCT | yes | NetLicensing product number |
FASTSPRING_WEBHOOK_SECRET | yes | The HMAC SHA256 Secret of the FastSpring webhook URL endpoint |
PRODUCT_MAP | yes | Path of products.json |
ACCEPT_TEST_EVENTS | no | true processes test orders; default: acknowledged and ignored |
LISTEN_ADDRESS, LISTEN_PORT | no | Default 127.0.0.1 and 8080 |
WEBHOOK_PATH | no | Default /fastspring |
FASTSPRING_API_USER, FASTSPRING_API_PASSWORD | backfill only | FastSpring API credentials |
BACKFILL_DAYS | backfill only | Days to look back, 1 to 30; default 3 |
The receiver
#!/usr/bin/env python3
"""FastSpring -> NetLicensing webhook receiver (foundation script, Python 3.9+, standard library only).
Receives FastSpring server webhooks, verifies the X-FS-Signature header and creates or deactivates
licenses through the NetLicensing REST API. See https://netlicensing.io/wiki/fastspring-licensing
Configuration (environment variables):
NETLICENSING_API_KEY API key with role ROLE_APIKEY_OPERATION
NETLICENSING_PRODUCT NetLicensing product number
FASTSPRING_WEBHOOK_SECRET the "HMAC SHA256 Secret" of the FastSpring webhook URL endpoint
PRODUCT_MAP path to a JSON file: {"<FastSpring product path>": "<licenseTemplateNumber>", ...}
ACCEPT_TEST_EVENTS "true" to process test orders (live=false); default: acknowledge and ignore them
LISTEN_ADDRESS, LISTEN_PORT where to listen (default 127.0.0.1:8080; put an HTTPS reverse proxy in front)
WEBHOOK_PATH URL path of the receiver (default /fastspring)
"""
import base64
import hashlib
import hmac
import json
import logging
import os
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
NLIC_BASE = "https://go.netlicensing.io/core/v2/rest"
log = logging.getLogger("fastspring-netlicensing")
def env(name, default=None):
value = os.environ.get(name, default)
if value is None:
raise SystemExit(f"Missing environment variable {name}")
return value
# ---------------------------------------------------------------------------- NetLicensing API
class NetLicensingError(Exception):
def __init__(self, status, code, message):
super().__init__(f"HTTP {status} {code}: {message}")
self.status, self.code = status, code
def nlic_call(method, path, fields=None):
"""Call the NetLicensing REST API; return the parsed JSON or raise NetLicensingError."""
data = urllib.parse.urlencode(fields).encode() if fields is not None else None
request = urllib.request.Request(NLIC_BASE + path, data=data, method=method)
token = base64.b64encode(f"apiKey:{os.environ['NETLICENSING_API_KEY']}".encode()).decode()
request.add_header("Authorization", "Basic " + token)
request.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", "replace")
try:
info = json.loads(body)["infos"]["info"][0]
raise NetLicensingError(error.code, info["id"], info["value"]) from None
except (ValueError, KeyError, IndexError, TypeError):
raise NetLicensingError(error.code, "HTTPError", body[:200]) from None
def quote(number):
return urllib.parse.quote(number, safe="")
def ensure_licensee(number, name=None, email=None):
"""Create the licensee; an existing number (or alias) is fine."""
fields = {"productNumber": os.environ["NETLICENSING_PRODUCT"], "number": number}
if name:
fields["name"] = name
if email:
fields["aliases"] = json.dumps([email])
try:
nlic_call("POST", "/licensee", fields)
log.info("licensee %s created", number)
except NetLicensingError as error:
if error.code != "DuplicateEntityException":
raise
def create_license(number, licensee, template):
"""Create one license; a repeated number means it was already done."""
try:
nlic_call("POST", "/license",
{"number": number, "licenseeNumber": licensee, "licenseTemplateNumber": template})
log.info("license %s created for %s", number, licensee)
except NetLicensingError as error:
if error.code != "DuplicateEntityException":
raise
log.info("license %s already exists", number)
def deactivate_license(number):
"""Deactivate one license; a license that does not exist is ignored."""
try:
nlic_call("POST", "/license/" + quote(number), {"active": "false"})
log.info("license %s deactivated", number)
except NetLicensingError as error:
if error.code != "NotFoundException":
raise
def licensed_sequence(licensee, subscription_id):
"""Last billing period (FastSpring 'sequence') licensed for a subscription, stored on the licensee."""
result = nlic_call("GET", "/licensee/" + quote(licensee))
props = {p["name"]: p["value"] for p in result["items"]["item"][0]["property"]}
value = props.get("fsSeq_" + subscription_id)
return int(value) if value else None
def remember_sequence(licensee, subscription_id, sequence):
nlic_call("POST", "/licensee/" + quote(licensee), {"fsSeq_" + subscription_id: str(sequence)})
# ---------------------------------------------------------------------------- FastSpring events
def account_id(account):
"""'account' is an id string, or an object when webhook expansion is enabled."""
return account.get("id") if isinstance(account, dict) else account
def require_expanded(value, what):
if not isinstance(value, dict):
raise ValueError(f"{what} is not expanded: enable Webhook Expansion for this webhook")
return value
def on_order_completed(data, product_map):
"""First purchase: one license per mapped item, numbered <order id>-<product path>."""
order_id = data["id"]
account = account_id(data["account"])
contact = data["account"].get("contact", {}) if isinstance(data["account"], dict) else {}
customer = data.get("customer") or {}
email = contact.get("email") or customer.get("email")
name = " ".join(x for x in (customer.get("first"), customer.get("last")) if x) or None
ensure_licensee(account, name, email)
for item in data.get("items", []):
product = item["product"]
template = product_map.get(product)
if not template:
log.warning("order %s: product %s is not in PRODUCT_MAP, skipped", order_id, product)
continue
subscription = item.get("subscription")
if subscription is not None:
subscription = require_expanded(subscription, "items[].subscription")
if subscription.get("state") == "trial":
log.info("order %s: %s starts with a trial, no paid license yet", order_id, product)
continue
create_license(f"{order_id}-{product}", account, template)
if "sequence" in subscription:
remember_sequence(account, subscription["id"], subscription["sequence"])
else:
log.warning("order %s: subscription %s has no sequence", order_id, subscription["id"])
else:
create_license(f"{order_id}-{product}", account, template)
def on_charge_completed(data, product_map):
"""Rebill: one license per new billing period; a charge in an already licensed period
(FastSpring also sends this event for prorated charges) adds nothing."""
order = require_expanded(data["order"], "order")
subscription = require_expanded(data["subscription"], "subscription")
account = account_id(data["account"])
sequence = int(data["sequence"])
sub_id = subscription["id"]
product = next((i["product"] for i in order.get("items", []) if account_id(i.get("subscription")) == sub_id),
subscription.get("product"))
template = product_map.get(product)
if not template:
log.warning("charge %s: product %s is not in PRODUCT_MAP, skipped", order["id"], product)
return
ensure_licensee(account)
last = licensed_sequence(account, sub_id)
if last is not None and sequence <= last:
log.info("charge %s: period %s of %s is already licensed, nothing to add", order["id"], sequence, sub_id)
return
create_license(f"{order['id']}-{product}", account, template)
remember_sequence(account, sub_id, sequence)
def on_return_created(data, product_map):
"""Refund or return: deactivate the licenses of the returned items."""
original = data["original"]
order_id = original.get("id") or original.get("order")
for item in data.get("items", []):
deactivate_license(f"{order_id}-{item['product']}")
def on_chargeback_created(data, product_map):
"""Chargeback: the event names only the order, so try every mapped product."""
for product in product_map:
deactivate_license(f"{data['order']}-{product}")
HANDLERS = {
"order.completed": on_order_completed,
"subscription.charge.completed": on_charge_completed,
"return.created": on_return_created,
"chargeback.created": on_chargeback_created,
}
def handle_event(event, product_map, accept_test=False):
"""Process one FastSpring event. Return True if it is done (processed or deliberately ignored)."""
kind = event.get("type")
handler = HANDLERS.get(kind)
if handler is None:
return True
if not event.get("live", False) and not accept_test:
log.info("event %s (%s) is a test event, ignored", event.get("id"), kind)
return True
try:
handler(event.get("data") or {}, product_map)
return True
except Exception:
log.exception("event %s (%s) failed", event.get("id"), kind)
return False
# ---------------------------------------------------------------------------- HTTP receiver
def signature_ok(raw_body, header, secret):
expected = base64.b64encode(hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, header or "")
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != os.environ.get("WEBHOOK_PATH", "/fastspring"):
return self.answer(404, "not found")
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not signature_ok(raw, self.headers.get("X-FS-Signature"), os.environ["FASTSPRING_WEBHOOK_SECRET"]):
log.warning("rejected a request with a missing or wrong X-FS-Signature")
return self.answer(401, "invalid signature")
events = json.loads(raw).get("events", [])
done = [e["id"] for e in events if handle_event(e, self.server.product_map, self.server.accept_test)]
if len(done) == len(events):
return self.answer(200, "OK") # all events processed
if done:
return self.answer(202, "\n".join(done)) # only these ids are processed; FastSpring retries the rest
return self.answer(500, "failed") # nothing processed; FastSpring retries
def answer(self, status, text):
body = text.encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
log.info("%s %s", self.address_string(), fmt % args)
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
for name in ("NETLICENSING_API_KEY", "NETLICENSING_PRODUCT", "FASTSPRING_WEBHOOK_SECRET"):
env(name)
with open(env("PRODUCT_MAP")) as f:
product_map = json.load(f)
server = ThreadingHTTPServer((os.environ.get("LISTEN_ADDRESS", "127.0.0.1"),
int(os.environ.get("LISTEN_PORT", "8080"))), Receiver)
server.product_map = product_map
server.accept_test = os.environ.get("ACCEPT_TEST_EVENTS", "false").lower() == "true"
log.info("listening on %s:%s%s", *server.server_address, os.environ.get("WEBHOOK_PATH", "/fastspring"))
server.serve_forever()
if __name__ == "__main__":
main()
How the receiver handles each event
| Function | Event | NetLicensing calls |
|---|---|---|
on_order_completed | order.completed | POST /licensee (number = account id, alias = email); POST /license per mapped item, number <order id>-<product path>; for a paid subscription item POST /licensee/{account} with fsSeq_<subscription id>=<sequence> |
on_charge_completed | subscription.charge.completed | GET /licensee/{account} to read fsSeq_<subscription id>; if sequence is higher: POST /license and update fsSeq_... |
on_return_created | return.created | POST /license/<original.id>-<product path> with active=false per returned item |
on_chargeback_created | chargeback.created | POST /license/<order>-<product path> with active=false for every mapped product |
NetLicensing answers a create with an existing number with HTTP 400 and DuplicateEntityException, and a deactivation of a license that does not exist with 400 and NotFoundException. The receiver treats both as done, which makes every handler safe to repeat. Any other error fails the event, so FastSpring retries it.
Backfill missed events
Run it on a schedule (see Go live). It reads GET https://api.fastspring.com/events/unprocessed, passes each event to the same handlers, and marks the successful ones with POST /events/{id} and {"processed": true}. FastSpring requires Basic authentication, a User-Agent header and Content-Type: application/json on its API; the script sends all three. FASTSPRING_API_BASE can point the script at a test server.
#!/usr/bin/env python3
"""Process FastSpring events that the receiver missed, then mark them processed (run it on a schedule).
Uses the same configuration as fastspring_netlicensing.py, plus:
FASTSPRING_API_USER, FASTSPRING_API_PASSWORD FastSpring API credentials
BACKFILL_DAYS how many days back to look (1-30, default 3)
FastSpring's /events endpoint only covers events posted to the first webhook on the Webhooks page.
"""
import base64
import json
import logging
import os
import urllib.request
import fastspring_netlicensing as receiver
FS_API = os.environ.get("FASTSPRING_API_BASE", "https://api.fastspring.com")
log = logging.getLogger("fastspring-backfill")
def fs_call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(FS_API + path, data=data, method=method)
creds = f"{os.environ['FASTSPRING_API_USER']}:{os.environ['FASTSPRING_API_PASSWORD']}"
request.add_header("Authorization", "Basic " + base64.b64encode(creds.encode()).decode())
request.add_header("User-Agent", "netlicensing-fastspring-backfill/1.0") # FastSpring requires a User-Agent
request.add_header("Content-Type", "application/json")
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
with open(receiver.env("PRODUCT_MAP")) as f:
product_map = json.load(f)
accept_test = os.environ.get("ACCEPT_TEST_EVENTS", "false").lower() == "true"
days = int(os.environ.get("BACKFILL_DAYS", "3"))
seen = set()
while True:
events = fs_call("GET", f"/events/unprocessed?days={days}").get("events", [])
new = [e for e in events if e["id"] not in seen]
if not new:
break
for event in new:
seen.add(event["id"])
if receiver.handle_event(event, product_map, accept_test):
fs_call("POST", f"/events/{event['id']}", {"processed": True})
log.info("event %s (%s) processed", event["id"], event.get("type"))
else:
log.warning("event %s (%s) failed; it stays unprocessed", event["id"], event.get("type"))
log.info("done: %d events looked at", len(seen))
if __name__ == "__main__":
main()
How we tested it
We ran the receiver locally against the live NetLicensing API (a test product, deleted afterwards) and posted signed events built from FastSpring’s documented payloads:
| Scenario | Answer | Result in NetLicensing |
|---|---|---|
Wrong X-FS-Signature | 401 | Nothing |
order.completed with a subscription item and a one-time item | 200 | Licensee with email alias, two licenses; validation by email is valid |
| The same event again | 200 | Nothing new |
subscription.charge.completed, sequence 2 | 200 | One more license; expires one month later |
A second charge with sequence 2 (proration) | 200 | Nothing new; expires unchanged |
return.created for the rebill order | 200 | That license active=false; expires one month earlier |
chargeback.created for the first order | 200 | Both licenses of that order active=false |
A test-mode event (live: false) | 200 | Ignored |
| One good event and one without webhook expansion | 202 with the good event’s id | One license; the other event stays unprocessed |
| An event type the receiver does not use | 200 | Nothing |
The backfill script was run against a local server that answers like FastSpring’s /events API; it processed the event and marked it processed. It was not run against FastSpring itself.
Back to FastSpring Licensing Integration.