Skip to content

CRM and email automation

Keep client profiles and segmentation in an external CRM without repeatedly polling the complete client list.

What you can synchronize

Change Public API event Payload resource
Member created client.created Client
Member profile updated client.updated Client
Tag added to a member client.tag_added {client_id, tag_id} relation
Tag removed from a member client.tag_removed {client_id, tag_id} relation

Subscription events are not part of the current Public API webhook catalogue. For invoice synchronization, use V2 invoice webhooks, which report invoice and line-item mutations. Do not build a new integration against the legacy Back Office billing or invoice payloads.

Architecture

Public API webhook
    → verify signature and deduplicate delivery
    → read the Client object from data
    → upsert the CRM contact

Public API webhook data already matches the unexpanded V1 Client retrieve response. The CRM fields listed below exist in both the V1 and V2 Client, so the same upsert code reads a webhook payload and a V2 response. An enrichment request is only required when the integration needs a related resource that is not in that model.

Initial import

Import existing clients before activating webhook registrations:

import time

import requests


# YOUR_ENV_DOMAIN: public.staging.bsport.io or public.production.bsport.io
BASE_URL = "https://YOUR_ENV_DOMAIN"
HEADERS = {
    "X-Api-Key": "YOUR_API_KEY",
    "X-Client-ID": "YOUR_CLIENT_ID",
    "X-Company-ID": "YOUR_COMPANY_ID",
}


def fetch_all_clients() -> list[dict]:
    clients = []
    page = 1
    while True:
        response = requests.get(
            f"{BASE_URL}/api/v2/management/clients/",
            headers=HEADERS,
            params={"page_size": 100, "page": page},
        )
        response.raise_for_status()
        data = response.json()
        clients.extend(data["results"])
        page = data["next_page"]
        if page is None:
            return clients
        time.sleep(0.3)

Use the Client id as the stable external identifier. Useful CRM fields include email, firstname, lastname, phone_number, tag_ids, is_archived, is_email_accepted and is_sms_accepted.

Process ongoing changes

Create V2 registrations for the four client events and route them to the same receiver:

from flask import Flask, jsonify, request


app = Flask(__name__)
MEMBER_RESOURCE_EVENTS = {
    "client.created",
    "client.updated",
}
TAG_RELATION_EVENTS = {
    "client.tag_added",
    "client.tag_removed",
}


@app.post("/webhook/bsport")
def handle_bsport_event():
    raw_body = request.get_data()
    verify_bsport_signature(raw_body, request.headers["Bsport-Signature"])

    delivery_id = request.headers["X-Bsport-Webhook-Delivery-Id"]
    if delivery_was_processed(delivery_id):
        return jsonify({"status": "already_processed"}), 200

    payload = request.get_json()
    event_type = payload["event_type"]
    if event_type in MEMBER_RESOURCE_EVENTS:
        client = payload["data"]
        upsert_crm_contact(external_id=client["id"], client=client)
    elif event_type in TAG_RELATION_EVENTS:
        relation = payload["data"]
        update_crm_tag(
            client_id=relation["client_id"],
            tag_id=relation["tag_id"],
            applied=event_type == "client.tag_added",
        )

    mark_delivery_processed(delivery_id)
    return jsonify({"status": "accepted"}), 200

Signature verification must use the raw request bytes before JSON parsing. See Delivery, signatures and retries for the complete algorithm.

Keep tag mappings readable

Client resource payloads contain the current tag_ids list. Relation events instead contain one numeric client_id and one numeric tag_id. Maintain a mapping between tag identifiers and the labels used by your CRM; apply or remove exactly the relation named by the event.

Send messages from your CRM

V2 can queue a plain-text email or SMS to up to 100 clients per request. The client list is not an exclusive recipient list, and a retry can send duplicates. Read Send an email or SMS before you use it.

Fallback polling

The clients endpoint does not currently expose an incremental updated_at filter. A polling fallback must therefore paginate the complete list and compare it with local state. Prefer webhooks for ongoing synchronization and keep polling as a lower-frequency reconciliation job.